fix: scope proposals to chat users
This commit is contained in:
@@ -86,14 +86,14 @@ gori-agent list
|
|||||||
- 每个 ACP worker 使用独立进程组和随机 `GORI_AGENT_WORKER_TOKEN`;cancel、timeout、crash 或 assistant 隔离违约必须清理同进程组工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱组属于无 cgroup/Bubblewrap 方案的边界。
|
- 每个 ACP worker 使用独立进程组和随机 `GORI_AGENT_WORKER_TOKEN`;cancel、timeout、crash 或 assistant 隔离违约必须清理同进程组工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱组属于无 cgroup/Bubblewrap 方案的边界。
|
||||||
- assistant 只接受 Kimi Code ACP,并依赖项目级 `tools: []`、`subagents: []` profile;permission deny 只是附加层。
|
- assistant 只接受 Kimi Code ACP,并依赖项目级 `tools: []`、`subagents: []` profile;permission deny 只是附加层。
|
||||||
- `src/acp/assistant-manager.ts`
|
- `src/acp/assistant-manager.ts`
|
||||||
- 每 chat 无工具 Assistant 会话(`GORI_ASSISTANT_ACTION_V1` envelope:create_proposal/confirm/start_next/cancel/stop,格式只修复一次)。
|
- 每 conversation(chat + user)一个无工具 Assistant 会话,同群不同用户互相隔离(`GORI_ASSISTANT_ACTION_V1` envelope:create_proposal/confirm/start_next/cancel/stop,格式只修复一次)。
|
||||||
- 唯一活跃 Worker 执行已确认 Proposal(`GORI_WORKER_RESULT_V1`:SUCCESS/FAILED/NEEDS_CONFIRMATION);success 与 failure 都需用户确认才收尾,不自动开始下一个;执行中 dirty 是正常的 NEEDS_CONFIRMATION 确认点。
|
- 唯一活跃 Worker 执行已确认 Proposal(`GORI_WORKER_RESULT_V1`:SUCCESS/FAILED/NEEDS_CONFIRMATION);success 与 failure 都需用户确认才收尾,不自动开始下一个;执行中 dirty 是正常的 NEEDS_CONFIRMATION 确认点。
|
||||||
- capacity(maxAssistantSessions/maxProcesses)、idle sweep、cancel/confirm/stop、worker_lost 恢复、owner 事件通知。
|
- capacity(maxAssistantSessions/maxProcesses)、idle sweep、cancel/confirm/stop、worker_lost 恢复、owner 事件通知。
|
||||||
- `src/core/durable-session-store.ts`
|
- `src/core/durable-session-store.ts`
|
||||||
- state v3 只保存 bot/platform identity 和 assistant binding(chat key、agent ID、native session ID、assistant workspace、fingerprint、时间戳);不保存消息正文。
|
- state v3 只保存 bot/platform identity 和 assistant binding(conversation key,即 chat + user、agent ID、native session ID、assistant workspace、fingerprint、时间戳);不保存消息正文。
|
||||||
- 单 writer lock、串行持久化、临时文件、fsync、原子 rename;version/identity 不匹配(含旧 v1/v2)拒绝并保留原文件。
|
- 单 writer lock、串行持久化、临时文件、fsync、原子 rename;version/identity 不匹配(含旧 v1/v2)拒绝并保留原文件。
|
||||||
- `src/core/proposal-store.ts`
|
- `src/core/proposal-store.ts`
|
||||||
- proposals.json(version 1)保存 Proposal:title/goal/steps、owner chat、状态流(proposed/queued/working/awaiting_user_confirmation/completed/failed/cancelled)、pending(step/success/failure)、worker session 与进程组 PGID/token;同样的 lock 与原子写入纪律。
|
- proposals.json(version 1)保存 Proposal:title/goal/steps、owner chat、发起用户 requesterUserId、状态流(proposed/queued/working/awaiting_user_confirmation/completed/failed/cancelled)、pending(step/success/failure)、worker session 与进程组 PGID/token;同样的 lock 与原子写入纪律。
|
||||||
- `src/core/workspace-scope.ts`
|
- `src/core/workspace-scope.ts`
|
||||||
- canonical workspace + 跨实例 Config v3 扫描;相同或父子 workspace 在 doctor/start/runner fail closed。lease 已退役。
|
- canonical workspace + 跨实例 Config v3 扫描;相同或父子 workspace 在 doctor/start/runner fail closed。lease 已退役。
|
||||||
- `src/core/gateway.ts`
|
- `src/core/gateway.ts`
|
||||||
@@ -184,7 +184,7 @@ permission policy 只审批 ACP request,不会注册工具。persona 不是安
|
|||||||
- `gateway.platform`: `type` discriminated union,只能是 qq、feishu、wecom、webhook、weixin 之一。
|
- `gateway.platform`: `type` discriminated union,只能是 qq、feishu、wecom、webhook、weixin 之一。
|
||||||
- 对象存在即启用,无 `enabled` 字段。
|
- 对象存在即启用,无 `enabled` 字段。
|
||||||
|
|
||||||
QQ 群 chat ID 为 `group:<group_openid>`。正式运维 Bot 应配置入口 allowlist。
|
QQ 群 chat ID 为 `group:<group_openid>`;用户 ID 按 QQ 官方语义取值:群聊作者用 `member_openid`,C2C 私聊作者用 `user_openid`。正式运维 Bot 应配置入口 allowlist。
|
||||||
|
|
||||||
### 4.5 Runtime ACP
|
### 4.5 Runtime ACP
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ Kimi Code agent `args` 必须严格为 `["acp"]`,不能添加可能绕过 assi
|
|||||||
|
|
||||||
## 5. Assistant / Proposal / Worker 与 state v3
|
## 5. Assistant / Proposal / Worker 与 state v3
|
||||||
|
|
||||||
运行链路是三层:每 chat 一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单。
|
运行链路是三层:每 conversation(chat + user)一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单,owner 是 chat + 发起用户,只有发起人本人可 confirm/stop/cancel/list,`start_next` 也只启动发起人自己的 queued Proposal。
|
||||||
|
|
||||||
Proposal 状态流:`proposed → queued → working → awaiting_user_confirmation → completed | failed | cancelled`。`proposed` 必须用户确认才进入 `queued`;没有活跃 worker、没有 working/awaiting_user_confirmation 时最早确认的 queued Proposal 才开始。
|
Proposal 状态流:`proposed → queued → working → awaiting_user_confirmation → completed | failed | cancelled`。`proposed` 必须用户确认才进入 `queued`;没有活跃 worker、没有 working/awaiting_user_confirmation 时最早确认的 queued Proposal 才开始。
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ Assistant 隔离:cwd 在实例私有 `state/assistant-workspaces/`,目录 ke
|
|||||||
|
|
||||||
state v3(`acp-sessions.json`)header 保存 `botId` 和 platform,只存 assistant binding(agent ID、assistant workspace、native session ID、Bot fingerprint、时间戳);`proposals.json`(version 1)存 Proposal 记录。两者打开时 version/identity 不匹配必须拒绝,不能清空、迁移或覆盖;旧 state v1/v2 也明确拒绝并保留原文件。fingerprint 包含 bootstrap schema version、Bot ID、workspace/persona、agent 定义、permission policy、skill 路径和内容 hash。prompt 失败不重放。
|
state v3(`acp-sessions.json`)header 保存 `botId` 和 platform,只存 assistant binding(agent ID、assistant workspace、native session ID、Bot fingerprint、时间戳);`proposals.json`(version 1)存 Proposal 记录。两者打开时 version/identity 不匹配必须拒绝,不能清空、迁移或覆盖;旧 state v1/v2 也明确拒绝并保留原文件。fingerprint 包含 bootstrap schema version、Bot ID、workspace/persona、agent 定义、permission policy、skill 路径和内容 hash。prompt 失败不重放。
|
||||||
|
|
||||||
命令 `/help`、`/status`、`/list`、`/confirm`、`/stop`、`/cancel` 旁路 Assistant;`/stop` 仅 owner chat 可停止活跃 worker。
|
命令 `/help`、`/status`、`/list`、`/confirm`、`/stop`、`/cancel` 旁路 Assistant;`/stop` 仅 Proposal 发起人(chat + user)可用,且对等待确认的 Proposal 按 pending 类型兜底落定(success→completed、failure→failed、step→cancelled),不自动开始下一个。
|
||||||
|
|
||||||
## 6. 单平台装配
|
## 6. 单平台装配
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ QQ websocket 示例:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
正式 Bot 应通过 `gateway.policy.allowedUsers` / `allowedChats` 限制入口。QQ 群 chat ID 为 `group:<group_openid>`。
|
正式 Bot 应通过 `gateway.policy.allowedUsers` / `allowedChats` 限制入口。QQ 群 chat ID 为 `group:<group_openid>`;用户 ID 按 QQ 官方语义取值:群聊作者用 `member_openid`,C2C 私聊作者用 `user_openid`。
|
||||||
|
|
||||||
### ACP 生命周期
|
### ACP 生命周期
|
||||||
|
|
||||||
@@ -188,8 +188,8 @@ QQ websocket 示例:
|
|||||||
|
|
||||||
运行链路是三层:
|
运行链路是三层:
|
||||||
|
|
||||||
- **Assistant**:每个 chat 一个无工具 ACP 会话,只与用户对话。它把用户意图整理成 Proposal(title、goal、steps),并解释 Worker 的反馈。Assistant 每条回复必须以隐藏 `GORI_ASSISTANT_ACTION_V1` envelope 结尾(`reply` + `actions`),action 只有 `create_proposal`、`confirm`、`start_next`、`cancel`、`stop`;格式错误只修复一次。
|
- **Assistant**:每个 conversation(chat + user)一个无工具 ACP 会话,只与用户对话;同群不同用户的会话互相隔离。它把用户意图整理成 Proposal(title、goal、steps),并解释 Worker 的反馈。Assistant 每条回复必须以隐藏 `GORI_ASSISTANT_ACTION_V1` envelope 结尾(`reply` + `actions`),action 只有 `create_proposal`、`confirm`、`start_next`、`cancel`、`stop`;格式错误只修复一次。
|
||||||
- **Proposal**:一份待确认的工作单。状态流为 `proposed → queued → working → awaiting_user_confirmation → completed | failed | cancelled`。`proposed` 只有用户确认后才进入 `queued`;空闲时最早确认的 queued Proposal 才开始执行。
|
- **Proposal**:一份待确认的工作单,owner 是 chat + 发起用户;只有发起人本人可以 confirm/stop/cancel/list 它,`start_next` 也只启动发起人自己的 queued Proposal。状态流为 `proposed → queued → working → awaiting_user_confirmation → completed | failed | cancelled`。`proposed` 只有用户确认后才进入 `queued`;空闲时最早确认的 queued Proposal 才开始执行。
|
||||||
- **Worker**:同一时刻全实例只有一个,在 `bot.workspace` 用 `bot.permissions` policy 执行一个已确认 Proposal。每轮必须以隐藏 `GORI_WORKER_RESULT_V1` envelope 收尾:`SUCCESS`、`FAILED` 或 `NEEDS_CONFIRMATION`(可带 `question`、`nextStep`、`dirty`)。
|
- **Worker**:同一时刻全实例只有一个,在 `bot.workspace` 用 `bot.permissions` policy 执行一个已确认 Proposal。每轮必须以隐藏 `GORI_WORKER_RESULT_V1` envelope 收尾:`SUCCESS`、`FAILED` 或 `NEEDS_CONFIRMATION`(可带 `question`、`nextStep`、`dirty`)。
|
||||||
|
|
||||||
确认语义是刻意的:
|
确认语义是刻意的:
|
||||||
@@ -197,17 +197,18 @@ QQ websocket 示例:
|
|||||||
- `SUCCESS` 和 `FAILED` 都进入 `awaiting_user_confirmation`,都需要用户确认后才分别落定为 `completed` / `failed`;对 failed Proposal 用 answer `retry` 确认可以重试。
|
- `SUCCESS` 和 `FAILED` 都进入 `awaiting_user_confirmation`,都需要用户确认后才分别落定为 `completed` / `failed`;对 failed Proposal 用 answer `retry` 确认可以重试。
|
||||||
- 系统**不会**在一个 Proposal 完成后自动开始下一个;只有用户确认新 Proposal 或 Assistant 发出 `start_next` 才会推进队列。
|
- 系统**不会**在一个 Proposal 完成后自动开始下一个;只有用户确认新 Proposal 或 Assistant 发出 `start_next` 才会推进队列。
|
||||||
- Worker 执行中遇到 dirty 或意外目标时报告 `NEEDS_CONFIRMATION`,这是正常的执行中确认点,不是 blocked 终态;用户回答后同一 Worker 继续执行。
|
- Worker 执行中遇到 dirty 或意外目标时报告 `NEEDS_CONFIRMATION`,这是正常的执行中确认点,不是 blocked 终态;用户回答后同一 Worker 继续执行。
|
||||||
|
- `stop` 对 `awaiting_user_confirmation` 的 Proposal 有兜底语义:pending success 落定为 `completed`,pending failure 落定为 `failed`,pending step 落定为 `cancelled`(并清理 worker 状态);不会自动开始下一个 Proposal。
|
||||||
- Gateway 不再有 15/60/180/480 秒的固定时间提醒;Worker 落定结果时通过 Assistant 生成一条事件说明,由平台 adapter 作为**新消息**发给 owner chat(QQ 同样是新消息,不引用原消息)。
|
- Gateway 不再有 15/60/180/480 秒的固定时间提醒;Worker 落定结果时通过 Assistant 生成一条事件说明,由平台 adapter 作为**新消息**发给 owner chat(QQ 同样是新消息,不引用原消息)。
|
||||||
|
|
||||||
Assistant 隔离与旧 side Session 一致且更严格:
|
Assistant 隔离与旧 side Session 一致且更严格:
|
||||||
|
|
||||||
- cwd 位于实例私有 `state/assistant-workspaces/`,目录名由进程随机 salt 和 chat key 计算 HMAC,不直接编码 chat 标识,并且不能与项目 workspace 重叠;重启后按 binding 恢复同一目录。
|
- cwd 位于实例私有 `state/assistant-workspaces/`,目录名由进程随机 salt 和 conversation key(chat + user)计算 HMAC,不直接编码 chat 标识,并且不能与项目 workspace 重叠;重启后按 binding 恢复同一目录。
|
||||||
- 项目级 `agent.md` override 显式设置 `tools: []` 与 `subagents: []`;ACP 传入空 `mcpServers`;Assistant 只接受 Kimi Code ACP,并强制 permission deny 作为附加层。
|
- 项目级 `agent.md` override 显式设置 `tools: []` 与 `subagents: []`;ACP 传入空 `mcpServers`;Assistant 只接受 Kimi Code ACP,并强制 permission deny 作为附加层。
|
||||||
- 除非 `bot.agent.env` 已显式设置,Assistant 进程的 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`(`0700`),与真实用户配置隔离。
|
- 除非 `bot.agent.env` 已显式设置,Assistant 进程的 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`(`0700`),与真实用户配置隔离。
|
||||||
- 一旦出现 tool update 或 permission request,即视为隔离违约:当前 turn fail closed、终止整个 ACP 进程组并删除 binding。Worker 的 cancel、timeout、crash 同样按进程组清理工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱离进程组仍属于无 Bubblewrap/cgroup 方案的信任边界。
|
- 一旦出现 tool update 或 permission request,即视为隔离违约:当前 turn fail closed、终止整个 ACP 进程组并删除 binding。Worker 的 cancel、timeout、crash 同样按进程组清理工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱离进程组仍属于无 Bubblewrap/cgroup 方案的信任边界。
|
||||||
- 每个 ACP worker 以独立进程组运行并携带随机 `GORI_AGENT_WORKER_TOKEN`;runner 重启时会把仍处于 `working` 的 Proposal 校验 token 后清理旧进程组,并标记为 failed(`worker_lost`),交给用户确认或重试。
|
- 每个 ACP worker 以独立进程组运行并携带随机 `GORI_AGENT_WORKER_TOKEN`;runner 重启时会把仍处于 `working` 的 Proposal 校验 token 后清理旧进程组,并标记为 failed(`worker_lost`),交给用户确认或重试。
|
||||||
|
|
||||||
state v3(`acp-sessions.json`)只保存 header(version 3、`botId`、platform)和 Assistant binding:chat key、agent ID、native session ID、assistant workspace、Bot fingerprint 与时间戳。`proposals.json`(version 1)保存 Proposal 记录:title/goal/steps、owner chat、状态、pending(`step | success | failure`)、worker native session ID 与进程组 PGID/token、时间戳;不保存消息正文。两个 store 都做单 writer lock、串行持久化、临时文件 + fsync + 原子 rename;version 或 identity 不匹配(含旧 state v1/v2)一律拒绝启动并保留原文件,不清空、不迁移。
|
state v3(`acp-sessions.json`)只保存 header(version 3、`botId`、platform)和 Assistant binding:conversation key(chat + user)、agent ID、native session ID、assistant workspace、Bot fingerprint 与时间戳。`proposals.json`(version 1)保存 Proposal 记录:title/goal/steps、owner chat、发起用户、状态、pending(`step | success | failure`)、worker native session ID 与进程组 PGID/token、时间戳;不保存消息正文。两个 store 都做单 writer lock、串行持久化、临时文件 + fsync + 原子 rename;version 或 identity 不匹配(含旧 state v1/v2)一律拒绝启动并保留原文件,不清空、不迁移。
|
||||||
|
|
||||||
Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona、agent 定义、permission policy 和 skill 路径/内容 hash;fingerprint 或 agent 不匹配的 binding 会被丢弃并重建 Assistant 会话。失败 prompt 不会自动重放,因为工具操作可能已有副作用。
|
Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona、agent 定义、permission policy 和 skill 路径/内容 hash;fingerprint 或 agent 不匹配的 binding 会被丢弃并重建 Assistant 会话。失败 prompt 不会自动重放,因为工具操作可能已有副作用。
|
||||||
|
|
||||||
@@ -227,15 +228,15 @@ Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `/help`:显示自然语言使用说明和兜底命令列表。
|
- `/help`:显示自然语言使用说明和兜底命令列表。
|
||||||
- `/status`:显示固定 Bot、agent、workspace、Assistant 会话数、各状态 Proposal 计数、Worker 是否在执行及当前 chat 是否 owner。
|
- `/status`:显示固定 Bot、agent、workspace、Assistant 会话数、各状态 Proposal 计数、Worker 是否在执行及当前用户是否 owner。
|
||||||
- `/list`:列出当前 chat 拥有的 Proposal(id、状态、title)。
|
- `/list`:列出当前 chat 中当前用户拥有的 Proposal(id、状态、title)。
|
||||||
- `/confirm`:确认当前 chat 最近待确认的 Proposal(`proposed` 进入队列,`awaiting_user_confirmation` 落定完成/失败或带着回答继续)。
|
- `/confirm`:确认当前用户最近待确认的 Proposal(`proposed` 进入队列,`awaiting_user_confirmation` 落定完成/失败或带着回答继续)。
|
||||||
- `/stop`:停止当前正在执行的 Worker 并将其 Proposal 标记为 cancelled;只有 owner chat 可用。
|
- `/stop`:停止当前用户正在执行的 Worker 并将其 Proposal 标记为 cancelled;对等待确认的 Proposal 按 pending 类型兜底落定(success→completed、failure→failed、step→cancelled);只有 Proposal 发起人可用。
|
||||||
- `/cancel`:取消当前 chat 最近未开始的 Proposal(`proposed` / `queued`)。
|
- `/cancel`:取消当前用户最近未开始的 Proposal(`proposed` / `queued`)。
|
||||||
|
|
||||||
日常操作以自然语言为主,Assistant 会自己生成 confirm/cancel/stop 等 action;这些命令是旁路 Assistant 的兜底入口。未识别的 `/` 命令按普通消息交给 Assistant。
|
日常操作以自然语言为主,Assistant 会自己生成 confirm/cancel/stop 等 action;这些命令是旁路 Assistant 的兜底入口。未识别的 `/` 命令按普通消息交给 Assistant。
|
||||||
|
|
||||||
Gateway 不维护普通消息队列或 per-chat task lock,也不再有固定时间(15/60/180/480 秒)的处理中提醒;每条 allowlist 普通消息按 chat 串行交给 AssistantManager。Worker 落定结果(成功、失败或提问)时,AssistantManager 生成事件文案并经 `Gateway.sendEvent` 由平台 adapter 作为**新消息**发出(QQ 也是新消息,不引用原消息);发送失败只记录不含消息正文或 provider 错误详情的安全日志,不影响任务或后续发送。
|
Gateway 不维护普通消息队列或 per-chat task lock,也不再有固定时间(15/60/180/480 秒)的处理中提醒;每条 allowlist 普通消息按 conversation(chat + user)串行交给 AssistantManager。Worker 落定结果(成功、失败或提问)时,AssistantManager 生成事件文案并经 `Gateway.sendEvent` 由平台 adapter 作为**新消息**发出(QQ 也是新消息,不引用原消息);发送失败只记录不含消息正文或 provider 错误详情的安全日志,不影响任务或后续发送。
|
||||||
|
|
||||||
每条异步入站使用独立、串行的回复流,`replySequence` 从 1 动态递增;同步 webhook 直接返回 JSON 结果。
|
每条异步入站使用独立、串行的回复流,`replySequence` 从 1 动态递增;同步 webhook 直接返回 JSON 结果。
|
||||||
|
|
||||||
|
|||||||
@@ -99,42 +99,42 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
|
|
||||||
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
|
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
|
||||||
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
|
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
|
||||||
const chatKey = chatKeyFor(request.platform, request.chatId);
|
const conversationKey = assistantKeyFor(chatKeyFor(request.platform, request.chatId), request.userId);
|
||||||
const reply = await this.enqueueChat(chatKey, () => this.runAssistantTurn(chatKey, request));
|
const reply = await this.enqueueChat(conversationKey, () => this.runAssistantTurn(conversationKey, request));
|
||||||
return { text: reply, botId: this.bot.id, agentId: this.bot.agent.id, mode: "assistant" };
|
return { text: reply, botId: this.bot.id, agentId: this.bot.agent.id, mode: "assistant" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(platform: string, chatId: string): Promise<boolean> {
|
async cancel(platform: string, chatId: string, userId: string): Promise<boolean> {
|
||||||
const chatKey = chatKeyFor(platform, chatId);
|
const chatKey = chatKeyFor(platform, chatId);
|
||||||
return this.scheduler(() => this.cancelLocked(chatKey));
|
return this.scheduler(() => this.cancelLocked(chatKey, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirm(platform: string, chatId: string): Promise<boolean> {
|
async confirm(platform: string, chatId: string, userId: string): Promise<boolean> {
|
||||||
const chatKey = chatKeyFor(platform, chatId);
|
const chatKey = chatKeyFor(platform, chatId);
|
||||||
return this.scheduler(() => this.confirmLocked(chatKey));
|
return this.scheduler(() => this.confirmLocked(chatKey, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(platform: string, chatId: string): Promise<boolean> {
|
async stop(platform: string, chatId: string, userId: string): Promise<boolean> {
|
||||||
const chatKey = chatKeyFor(platform, chatId);
|
const chatKey = chatKeyFor(platform, chatId);
|
||||||
return this.scheduler(() => this.stopActiveLocked(chatKey));
|
return this.scheduler(() => this.stopActiveLocked(chatKey, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
listProposals(platform: string, chatId: string): Proposal[] {
|
listProposals(platform: string, chatId: string, userId: string): Proposal[] {
|
||||||
const chatKey = chatKeyFor(platform, chatId);
|
const chatKey = chatKeyFor(platform, chatId);
|
||||||
return this.proposals.list().filter((proposal) => proposal.ownerChatKey === chatKey);
|
return this.proposals.list().filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reset(platform: string, chatId: string): Promise<void> {
|
async reset(platform: string, chatId: string, userId: string): Promise<void> {
|
||||||
const chatKey = chatKeyFor(platform, chatId);
|
const conversationKey = assistantKeyFor(chatKeyFor(platform, chatId), userId);
|
||||||
const worker = this.assistantWorkers.get(chatKey);
|
const worker = this.assistantWorkers.get(conversationKey);
|
||||||
if (worker) {
|
if (worker) {
|
||||||
this.assistantWorkers.delete(chatKey);
|
this.assistantWorkers.delete(conversationKey);
|
||||||
await worker.terminate();
|
await worker.terminate();
|
||||||
}
|
}
|
||||||
await this.store.deleteBinding(chatKey);
|
await this.store.deleteBinding(conversationKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
status(platform: string, chatId: string): Record<string, string | number | boolean> {
|
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean> {
|
||||||
const proposals = this.proposals.list();
|
const proposals = this.proposals.list();
|
||||||
const active = this.active;
|
const active = this.active;
|
||||||
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
|
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
|
||||||
@@ -148,7 +148,7 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
workingProposals: proposals.filter((proposal) => proposal.status === "working").length,
|
workingProposals: proposals.filter((proposal) => proposal.status === "working").length,
|
||||||
awaitingConfirmation: proposals.filter((proposal) => proposal.status === "awaiting_user_confirmation").length,
|
awaitingConfirmation: proposals.filter((proposal) => proposal.status === "awaiting_user_confirmation").length,
|
||||||
workerRunning: Boolean(active?.worker.inFlight),
|
workerRunning: Boolean(active?.worker.inFlight),
|
||||||
owner: Boolean(activeProposal && activeProposal.ownerChatKey === chatKeyFor(platform, chatId))
|
owner: Boolean(userId && activeProposal && activeProposal.ownerChatKey === chatKeyFor(platform, chatId) && activeProposal.requesterUserId === userId)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,17 +181,17 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
await Promise.allSettled([...this.chatChains.values()]);
|
await Promise.allSettled([...this.chatChains.values()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runAssistantTurn(chatKey: string, request: ConversationRequest): Promise<string> {
|
private async runAssistantTurn(conversationKey: string, request: ConversationRequest): Promise<string> {
|
||||||
const worker = await this.acquireAssistantWorker(chatKey);
|
const worker = await this.acquireAssistantWorker(conversationKey);
|
||||||
try {
|
try {
|
||||||
const reply = await worker.prompt(this.assistantUserPrompt(request));
|
const reply = await worker.prompt(this.assistantUserPrompt(request));
|
||||||
const parsed = await this.parseAssistantReply(worker, reply);
|
const parsed = await this.parseAssistantReply(worker, reply);
|
||||||
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
|
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
|
||||||
await this.store.touchBinding(chatKey);
|
await this.store.touchBinding(conversationKey);
|
||||||
await this.executeActions(chatKey, request, parsed.actions);
|
await this.executeActions(chatKeyFor(request.platform, request.chatId), request, parsed.actions);
|
||||||
return parsed.reply;
|
return parsed.reply;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
|
if (worker.isolationViolated) await this.store.deleteBinding(conversationKey).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,23 +214,23 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
requesterUserId: request.userId
|
requesterUserId: request.userId
|
||||||
});
|
});
|
||||||
} else if (action.type === "confirm") {
|
} else if (action.type === "confirm") {
|
||||||
await this.scheduler(() => this.confirmLocked(chatKey, action.id, action.answer));
|
await this.scheduler(() => this.confirmLocked(chatKey, request.userId, action.id, action.answer));
|
||||||
} else if (action.type === "start_next") {
|
} else if (action.type === "start_next") {
|
||||||
await this.scheduler(() => this.tryStartLocked());
|
await this.scheduler(() => this.tryStartLocked(chatKey, request.userId));
|
||||||
} else if (action.type === "cancel") {
|
} else if (action.type === "cancel") {
|
||||||
await this.scheduler(() => this.cancelLocked(chatKey, action.id));
|
await this.scheduler(() => this.cancelLocked(chatKey, request.userId, action.id));
|
||||||
} else if (action.type === "stop") {
|
} else if (action.type === "stop") {
|
||||||
await this.scheduler(() => this.stopActiveLocked(chatKey));
|
await this.scheduler(() => this.stopActiveLocked(chatKey, request.userId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async confirmLocked(chatKey: string, id?: string, answer?: string): Promise<boolean> {
|
private async confirmLocked(chatKey: string, userId: string, id?: string, answer?: string): Promise<boolean> {
|
||||||
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "awaiting_user_confirmation"]);
|
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "awaiting_user_confirmation"]);
|
||||||
if (!proposal) return false;
|
if (!proposal) return false;
|
||||||
if (proposal.status === "proposed") {
|
if (proposal.status === "proposed") {
|
||||||
await this.proposals.update(proposal.id, { status: "queued", confirmedAt: Date.now() });
|
await this.proposals.update(proposal.id, { status: "queued", confirmedAt: Date.now() });
|
||||||
await this.tryStartLocked();
|
await this.tryStartLocked(chatKey, userId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const pending = proposal.pending;
|
const pending = proposal.pending;
|
||||||
@@ -255,25 +255,24 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cancelLocked(chatKey: string, id?: string): Promise<boolean> {
|
private async cancelLocked(chatKey: string, userId: string, id?: string): Promise<boolean> {
|
||||||
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "queued"]);
|
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "queued"]);
|
||||||
if (!proposal) return false;
|
if (!proposal) return false;
|
||||||
await this.proposals.update(proposal.id, { status: "cancelled", finishedAt: Date.now() });
|
await this.proposals.update(proposal.id, { status: "cancelled", finishedAt: Date.now() });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async stopActiveLocked(chatKey: string): Promise<boolean> {
|
private async stopActiveLocked(chatKey: string, userId: string): Promise<boolean> {
|
||||||
const active = this.active;
|
const active = this.active;
|
||||||
if (!active) return false;
|
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
|
||||||
const proposal = this.proposals.get(active.proposalId);
|
if (active && activeProposal && activeProposal.ownerChatKey === chatKey && activeProposal.requesterUserId === userId) {
|
||||||
if (!proposal || proposal.ownerChatKey !== chatKey) return false;
|
|
||||||
active.settled = true;
|
active.settled = true;
|
||||||
this.active = undefined;
|
this.active = undefined;
|
||||||
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
|
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
|
||||||
active.worker.terminateImmediately();
|
active.worker.terminateImmediately();
|
||||||
await active.worker.terminate().catch(() => undefined);
|
await active.worker.terminate().catch(() => undefined);
|
||||||
void active.run?.catch(() => undefined);
|
void active.run?.catch(() => undefined);
|
||||||
await this.proposals.update(proposal.id, {
|
await this.proposals.update(activeProposal.id, {
|
||||||
status: "cancelled",
|
status: "cancelled",
|
||||||
pending: undefined,
|
pending: undefined,
|
||||||
workerProcessGroup: undefined,
|
workerProcessGroup: undefined,
|
||||||
@@ -282,6 +281,23 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const waiting = this.resolveTargetProposal(chatKey, userId, undefined, ["awaiting_user_confirmation"]);
|
||||||
|
if (!waiting?.pending) return false;
|
||||||
|
await this.terminateActiveLocked(waiting.id);
|
||||||
|
if (waiting.pending.kind === "success") {
|
||||||
|
await this.proposals.update(waiting.id, { status: "completed", pending: undefined, finishedAt: Date.now() });
|
||||||
|
} else if (waiting.pending.kind === "failure") {
|
||||||
|
await this.proposals.update(waiting.id, { status: "failed", pending: undefined, finishedAt: Date.now() });
|
||||||
|
} else {
|
||||||
|
await this.proposals.update(waiting.id, {
|
||||||
|
status: "cancelled",
|
||||||
|
pending: undefined,
|
||||||
|
lastWorkerSummary: "stopped by user",
|
||||||
|
finishedAt: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async terminateActiveLocked(proposalId: string): Promise<void> {
|
private async terminateActiveLocked(proposalId: string): Promise<void> {
|
||||||
const active = this.active;
|
const active = this.active;
|
||||||
@@ -294,23 +310,25 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
await this.proposals.update(proposalId, { workerProcessGroup: undefined });
|
await this.proposals.update(proposalId, { workerProcessGroup: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryStartLocked(): Promise<void> {
|
private async tryStartLocked(chatKey: string, userId: string): Promise<void> {
|
||||||
if (this.active) return;
|
if (this.active) return;
|
||||||
if (this.proposals.list({ status: "working" }).length > 0) return;
|
if (this.proposals.list({ status: "working" }).length > 0) return;
|
||||||
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) return;
|
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) return;
|
||||||
const next = this.proposals.list({ status: "queued" })[0];
|
const next = this.proposals.list({ status: "queued" })
|
||||||
|
.find((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
await this.proposals.update(next.id, { status: "working", startedAt: Date.now() });
|
await this.proposals.update(next.id, { status: "working", startedAt: Date.now() });
|
||||||
this.launchWorker(next.id);
|
this.launchWorker(next.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTargetProposal(chatKey: string, id: string | undefined, statuses: Proposal["status"][]): Proposal | undefined {
|
private resolveTargetProposal(chatKey: string, userId: string, id: string | undefined, statuses: Proposal["status"][]): Proposal | undefined {
|
||||||
|
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
|
||||||
if (id) {
|
if (id) {
|
||||||
const proposal = this.proposals.get(id);
|
const proposal = this.proposals.get(id);
|
||||||
return proposal && proposal.ownerChatKey === chatKey && statuses.includes(proposal.status) ? proposal : undefined;
|
return proposal && owned(proposal) && statuses.includes(proposal.status) ? proposal : undefined;
|
||||||
}
|
}
|
||||||
return this.proposals.list()
|
return this.proposals.list()
|
||||||
.filter((proposal) => proposal.ownerChatKey === chatKey && statuses.includes(proposal.status))
|
.filter((proposal) => owned(proposal) && statuses.includes(proposal.status))
|
||||||
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
|
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,10 +455,11 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
const sink = this.eventSink;
|
const sink = this.eventSink;
|
||||||
if (!proposal || !sink) return;
|
if (!proposal || !sink) return;
|
||||||
const chatKey = proposal.ownerChatKey;
|
const chatKey = proposal.ownerChatKey;
|
||||||
void this.enqueueChat(chatKey, async () => {
|
const conversationKey = assistantKeyFor(chatKey, proposal.requesterUserId);
|
||||||
|
void this.enqueueChat(conversationKey, async () => {
|
||||||
if (this.shuttingDown) return;
|
if (this.shuttingDown) return;
|
||||||
try {
|
try {
|
||||||
const reply = await this.runAssistantEvent(chatKey, proposal.id);
|
const reply = await this.runAssistantEvent(conversationKey, proposal.id);
|
||||||
if (reply) await sink(chatKey, reply);
|
if (reply) await sink(chatKey, reply);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Assistant event notification failed (${error instanceof Error ? error.name : "unknown error"})`);
|
console.error(`Assistant event notification failed (${error instanceof Error ? error.name : "unknown error"})`);
|
||||||
@@ -450,37 +469,37 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runAssistantEvent(chatKey: string, proposalId: string): Promise<string> {
|
private async runAssistantEvent(conversationKey: string, proposalId: string): Promise<string> {
|
||||||
const proposal = this.proposals.get(proposalId);
|
const proposal = this.proposals.get(proposalId);
|
||||||
if (!proposal?.pending) return "";
|
if (!proposal?.pending) return "";
|
||||||
const worker = await this.acquireAssistantWorker(chatKey);
|
const worker = await this.acquireAssistantWorker(conversationKey);
|
||||||
try {
|
try {
|
||||||
const reply = await worker.prompt(assistantEventPrompt(proposal));
|
const reply = await worker.prompt(assistantEventPrompt(proposal));
|
||||||
const parsed = await this.parseAssistantReply(worker, reply);
|
const parsed = await this.parseAssistantReply(worker, reply);
|
||||||
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
|
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
|
||||||
await this.store.touchBinding(chatKey);
|
await this.store.touchBinding(conversationKey);
|
||||||
return parsed.reply;
|
return parsed.reply;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
|
if (worker.isolationViolated) await this.store.deleteBinding(conversationKey).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async acquireAssistantWorker(chatKey: string): Promise<AcpWorker> {
|
private async acquireAssistantWorker(conversationKey: string): Promise<AcpWorker> {
|
||||||
const existing = this.assistantWorkers.get(chatKey);
|
const existing = this.assistantWorkers.get(conversationKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.lastUsedAt = Date.now();
|
existing.lastUsedAt = Date.now();
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
await this.reserveAssistantCapacity();
|
await this.reserveAssistantCapacity();
|
||||||
const persisted = this.store.getBinding(chatKey);
|
const persisted = this.store.getBinding(conversationKey);
|
||||||
const binding = persisted && this.validBinding(persisted) ? persisted : undefined;
|
const binding = persisted && this.validBinding(persisted) ? persisted : undefined;
|
||||||
if (persisted && !binding) await this.store.deleteBinding(chatKey);
|
if (persisted && !binding) await this.store.deleteBinding(conversationKey);
|
||||||
const cwd = this.prepareAssistantWorkspace(chatKey, binding);
|
const cwd = this.prepareAssistantWorkspace(conversationKey, binding);
|
||||||
const spawnWorker = async (nativeSessionId?: string): Promise<AcpWorker> => {
|
const spawnWorker = async (nativeSessionId?: string): Promise<AcpWorker> => {
|
||||||
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => {
|
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => {
|
||||||
this.crashes++;
|
this.crashes++;
|
||||||
if (this.assistantWorkers.get(chatKey) === crashed) this.assistantWorkers.delete(chatKey);
|
if (this.assistantWorkers.get(conversationKey) === crashed) this.assistantWorkers.delete(conversationKey);
|
||||||
console.error(`ACP assistant worker crash: ${error.message}`);
|
console.error(`ACP assistant worker crash: ${error.message}`);
|
||||||
}, {
|
}, {
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
@@ -488,7 +507,7 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
env: this.assistantEnv(),
|
env: this.assistantEnv(),
|
||||||
policy: ASSISTANT_POLICY,
|
policy: ASSISTANT_POLICY,
|
||||||
onIsolationViolation: (violating) => {
|
onIsolationViolation: (violating) => {
|
||||||
if (this.assistantWorkers.get(chatKey) === violating) this.assistantWorkers.delete(chatKey);
|
if (this.assistantWorkers.get(conversationKey) === violating) this.assistantWorkers.delete(conversationKey);
|
||||||
},
|
},
|
||||||
allowUnverifiedAssistantAgent: this.options.allowUnverifiedAssistantAgent
|
allowUnverifiedAssistantAgent: this.options.allowUnverifiedAssistantAgent
|
||||||
});
|
});
|
||||||
@@ -501,17 +520,17 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
worker = await spawnWorker(binding?.nativeSessionId);
|
worker = await spawnWorker(binding?.nativeSessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
|
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
|
||||||
await this.store.deleteBinding(chatKey);
|
await this.store.deleteBinding(conversationKey);
|
||||||
resumed = false;
|
resumed = false;
|
||||||
worker = await spawnWorker();
|
worker = await spawnWorker();
|
||||||
}
|
}
|
||||||
this.assistantWorkers.set(chatKey, worker);
|
this.assistantWorkers.set(conversationKey, worker);
|
||||||
if (!resumed) {
|
if (!resumed) {
|
||||||
try {
|
try {
|
||||||
await worker.prompt(this.bot.assistantBootstrap, "initializing");
|
await worker.prompt(this.bot.assistantBootstrap, "initializing");
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await this.store.setBinding({
|
await this.store.setBinding({
|
||||||
chatKey,
|
chatKey: conversationKey,
|
||||||
agentId: this.bot.agent.id,
|
agentId: this.bot.agent.id,
|
||||||
nativeSessionId: worker.nativeSessionId!,
|
nativeSessionId: worker.nativeSessionId!,
|
||||||
assistantWorkspace: cwd,
|
assistantWorkspace: cwd,
|
||||||
@@ -520,7 +539,7 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
updatedAt: now
|
updatedAt: now
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.assistantWorkers.get(chatKey) === worker) this.assistantWorkers.delete(chatKey);
|
if (this.assistantWorkers.get(conversationKey) === worker) this.assistantWorkers.delete(conversationKey);
|
||||||
await worker.terminate().catch(() => undefined);
|
await worker.terminate().catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -573,20 +592,22 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private assistantUserPrompt(request: ConversationRequest): string {
|
private assistantUserPrompt(request: ConversationRequest): string {
|
||||||
|
const chatKey = chatKeyFor(request.platform, request.chatId);
|
||||||
return [
|
return [
|
||||||
"[User message]",
|
"[User message]",
|
||||||
request.text,
|
request.text,
|
||||||
"",
|
"",
|
||||||
"[Proposal states]",
|
"[Proposal states]",
|
||||||
...this.proposalSummaries(),
|
...this.proposalSummaries(chatKey, request.userId),
|
||||||
"",
|
"",
|
||||||
"[Worker state]",
|
"[Worker state]",
|
||||||
this.workerStateSummary()
|
this.workerStateSummary(chatKey, request.userId)
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
private proposalSummaries(): string[] {
|
private proposalSummaries(chatKey: string, userId: string): string[] {
|
||||||
const proposals = this.proposals.list();
|
const proposals = this.proposals.list()
|
||||||
|
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||||
if (proposals.length === 0) return ["none"];
|
if (proposals.length === 0) return ["none"];
|
||||||
return proposals.map((proposal) => {
|
return proposals.map((proposal) => {
|
||||||
const pending = proposal.pending
|
const pending = proposal.pending
|
||||||
@@ -596,9 +617,13 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private workerStateSummary(): string {
|
private workerStateSummary(chatKey: string, userId: string): string {
|
||||||
const active = this.active;
|
const active = this.active;
|
||||||
if (!active) return "no active worker";
|
if (!active) return "no active worker";
|
||||||
|
const proposal = this.proposals.get(active.proposalId);
|
||||||
|
if (!proposal || proposal.ownerChatKey !== chatKey || proposal.requesterUserId !== userId) {
|
||||||
|
return "busy: a worker is executing another user's proposal; details hidden";
|
||||||
|
}
|
||||||
const worker = active.worker;
|
const worker = active.worker;
|
||||||
const runningSeconds = worker.turnStartedAt ? Math.max(0, Math.floor((Date.now() - worker.turnStartedAt) / 1_000)) : 0;
|
const runningSeconds = worker.turnStartedAt ? Math.max(0, Math.floor((Date.now() - worker.turnStartedAt) / 1_000)) : 0;
|
||||||
return `proposal=${active.proposalId} phase=${worker.phase} inFlight=${worker.inFlight} runningSeconds=${runningSeconds}`;
|
return `proposal=${active.proposalId} phase=${worker.phase} inFlight=${worker.inFlight} runningSeconds=${runningSeconds}`;
|
||||||
@@ -620,8 +645,11 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined {
|
export function assistantKeyFor(chatKey: string, userId: string): string {
|
||||||
const match = ASSISTANT_ENVELOPE.exec(text);
|
return `${chatKey}#${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined { const match = ASSISTANT_ENVELOPE.exec(text);
|
||||||
if (!match?.groups) return undefined;
|
if (!match?.groups) return undefined;
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
|
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
|
||||||
|
|||||||
+6
-6
@@ -34,14 +34,14 @@ export type RuntimeEventSink = (chatKey: string, text: string) => Promise<void>;
|
|||||||
|
|
||||||
export interface ConversationRuntime {
|
export interface ConversationRuntime {
|
||||||
prompt(request: ConversationRequest): Promise<ConversationResponse>;
|
prompt(request: ConversationRequest): Promise<ConversationResponse>;
|
||||||
cancel(platform: string, chatId: string): Promise<boolean>;
|
cancel(platform: string, chatId: string, userId: string): Promise<boolean>;
|
||||||
reset(platform: string, chatId: string): Promise<void>;
|
reset(platform: string, chatId: string, userId: string): Promise<void>;
|
||||||
status(platform: string, chatId: string): Record<string, string | number | boolean>;
|
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean>;
|
||||||
stats(): RuntimeStats;
|
stats(): RuntimeStats;
|
||||||
shutdown(): Promise<void>;
|
shutdown(): Promise<void>;
|
||||||
listProposals?(platform: string, chatId: string): Proposal[];
|
listProposals?(platform: string, chatId: string, userId: string): Proposal[];
|
||||||
confirm?(platform: string, chatId: string): Promise<boolean>;
|
confirm?(platform: string, chatId: string, userId: string): Promise<boolean>;
|
||||||
stop?(platform: string, chatId: string): Promise<boolean>;
|
stop?(platform: string, chatId: string, userId: string): Promise<boolean>;
|
||||||
setEventSink?(sink: RuntimeEventSink): void;
|
setEventSink?(sink: RuntimeEventSink): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -139,25 +139,25 @@ export class Gateway {
|
|||||||
switch (command.kind) {
|
switch (command.kind) {
|
||||||
case "help": return HELP_TEXT;
|
case "help": return HELP_TEXT;
|
||||||
case "status": {
|
case "status": {
|
||||||
const status = this.runtime.status(message.platform, message.chatId);
|
const status = this.runtime.status(message.platform, message.chatId, message.userId);
|
||||||
return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`;
|
return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`;
|
||||||
}
|
}
|
||||||
case "list": {
|
case "list": {
|
||||||
if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
|
if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
|
||||||
const proposals = this.runtime.listProposals(message.platform, message.chatId);
|
const proposals = this.runtime.listProposals(message.platform, message.chatId, message.userId);
|
||||||
if (proposals.length === 0) return "当前没有提案。";
|
if (proposals.length === 0) return "当前没有提案。";
|
||||||
return proposals.map((proposal) => `${proposal.id} [${proposal.status}] ${proposal.title}`).join("\n");
|
return proposals.map((proposal) => `${proposal.id} [${proposal.status}] ${proposal.title}`).join("\n");
|
||||||
}
|
}
|
||||||
case "confirm": {
|
case "confirm": {
|
||||||
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
|
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
|
||||||
return await this.runtime.confirm(message.platform, message.chatId) ? "已确认,继续执行。" : "当前没有待确认的提案。";
|
return await this.runtime.confirm(message.platform, message.chatId, message.userId) ? "已确认,继续执行。" : "当前没有待确认的提案。";
|
||||||
}
|
}
|
||||||
case "stop": {
|
case "stop": {
|
||||||
if (!this.runtime.stop) return "当前运行时不支持停止操作。";
|
if (!this.runtime.stop) return "当前运行时不支持停止操作。";
|
||||||
return await this.runtime.stop(message.platform, message.chatId) ? "已停止当前任务。" : "当前没有正在执行的任务。";
|
return await this.runtime.stop(message.platform, message.chatId, message.userId) ? "已停止当前任务。" : "当前没有正在执行的任务。";
|
||||||
}
|
}
|
||||||
case "cancel":
|
case "cancel":
|
||||||
return await this.runtime.cancel(message.platform, message.chatId) ? "已取消最近的提案。" : "没有可取消的提案。";
|
return await this.runtime.cancel(message.platform, message.chatId, message.userId) ? "已取消最近的提案。" : "没有可取消的提案。";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export class QqAdapter implements PlatformAdapter {
|
|||||||
|
|
||||||
const isGroup = payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "AT_MESSAGE_CREATE" || Boolean(data.group_openid || data.group_id || data.channel_id || data.guild_id);
|
const isGroup = payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "AT_MESSAGE_CREATE" || Boolean(data.group_openid || data.group_id || data.channel_id || data.guild_id);
|
||||||
const chatId = chatIdFor(data, isGroup);
|
const chatId = chatIdFor(data, isGroup);
|
||||||
const userId = data.user_openid || data.author?.user_openid || data.author?.id || data.member_openid;
|
const userId = userIdFor(data, isGroup);
|
||||||
if (!chatId || !userId) return undefined;
|
if (!chatId || !userId) return undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -141,6 +141,11 @@ export class QqAdapter implements PlatformAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
|
||||||
|
if (isGroup) return data.author?.member_openid || data.member_openid || data.author?.user_openid || data.user_openid || data.author?.id;
|
||||||
|
return data.author?.user_openid || data.user_openid || data.author?.id || data.member_openid || data.author?.member_openid;
|
||||||
|
}
|
||||||
|
|
||||||
function chatIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
|
function chatIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
|
||||||
if (data.group_openid) return `group:${data.group_openid}`;
|
if (data.group_openid) return `group:${data.group_openid}`;
|
||||||
if (data.group_id) return `group:${data.group_id}`;
|
if (data.group_id) return `group:${data.group_id}`;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|||||||
import type { AppConfig, BotConfig } from "../config.js";
|
import type { AppConfig, BotConfig } from "../config.js";
|
||||||
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
|
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
|
||||||
|
|
||||||
const BOOTSTRAP_SCHEMA_VERSION = 4;
|
const BOOTSTRAP_SCHEMA_VERSION = 5;
|
||||||
|
|
||||||
export interface ResolvedBot extends BotConfig {
|
export interface ResolvedBot extends BotConfig {
|
||||||
loadedSkills: LoadedSkill[];
|
loadedSkills: LoadedSkill[];
|
||||||
@@ -44,7 +44,9 @@ function buildAssistantBootstrap(bot: BotConfig): string {
|
|||||||
"You are the user-facing Assistant. You have no tools and cannot inspect files, run commands, call Skills or MCP. You chat with the user, propose work, and explain worker feedback. Never claim to have executed anything yourself.",
|
"You are the user-facing Assistant. You have no tools and cannot inspect files, run commands, call Skills or MCP. You chat with the user, propose work, and explain worker feedback. Never claim to have executed anything yourself.",
|
||||||
"Every reply must end with exactly one hidden action envelope: <GORI_ASSISTANT_ACTION_V1>{\"reply\":\"...\",\"actions\":[...]}</GORI_ASSISTANT_ACTION_V1>. Put the user-facing text in the JSON \"reply\" field, not outside the envelope.",
|
"Every reply must end with exactly one hidden action envelope: <GORI_ASSISTANT_ACTION_V1>{\"reply\":\"...\",\"actions\":[...]}</GORI_ASSISTANT_ACTION_V1>. Put the user-facing text in the JSON \"reply\" field, not outside the envelope.",
|
||||||
"Supported actions: {\"type\":\"create_proposal\",\"title\":\"...\",\"goal\":\"...\",\"steps\":[\"...\"]}; {\"type\":\"confirm\",\"id\":\"optional\",\"answer\":\"optional\"}; {\"type\":\"start_next\"}; {\"type\":\"cancel\",\"id\":\"optional\"}; {\"type\":\"stop\"}. Use an empty actions array when no state change is needed.",
|
"Supported actions: {\"type\":\"create_proposal\",\"title\":\"...\",\"goal\":\"...\",\"steps\":[\"...\"]}; {\"type\":\"confirm\",\"id\":\"optional\",\"answer\":\"optional\"}; {\"type\":\"start_next\"}; {\"type\":\"cancel\",\"id\":\"optional\"}; {\"type\":\"stop\"}. Use an empty actions array when no state change is needed.",
|
||||||
"A proposal only starts after the user confirms it. Confirming a proposal whose worker reported SUCCESS marks it completed; confirming a FAILED proposal marks it failed; to retry a failed proposal, confirm with answer \"retry\". When a worker asks a question (NEEDS_CONFIRMATION), confirm with the user's answer to continue the same worker. \"stop\" terminates the active worker and cancels its proposal; \"start_next\" starts the oldest confirmed queued proposal. Never invent other actions or statuses."
|
"A proposal only starts after the user confirms it. Confirming a proposal whose worker reported SUCCESS marks it completed; confirming a FAILED proposal marks it failed; to retry a failed proposal, confirm with answer \"retry\". When a worker asks a question (NEEDS_CONFIRMATION), confirm with the user's answer to continue the same worker. \"stop\" terminates the active worker and cancels its proposal; \"start_next\" starts the oldest confirmed queued proposal. Never invent other actions or statuses.",
|
||||||
|
"In a group chat each proposal is owned by the user who requested it: only that user can confirm, stop, or cancel it. If another group member asks you to confirm, stop, or cancel a proposal they did not create, explain that only the proposal's initiator can do that and emit no action.",
|
||||||
|
"When a worker reported SUCCESS and the user says something like \"够了\", \"停止\", \"不用继续\" or \"that's enough\", treat it as accepting the finished work: emit a \"confirm\" action so the proposal settles as completed. Use \"stop\" only to abort work that is still running or waiting on an answer."
|
||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { AssistantManager, parseAssistantActions, parseWorkerResult } from "../src/acp/assistant-manager.js";
|
import { AssistantManager, assistantKeyFor, parseAssistantActions, parseWorkerResult } from "../src/acp/assistant-manager.js";
|
||||||
import { parseConfig } from "../src/config.js";
|
import { parseConfig } from "../src/config.js";
|
||||||
import { DurableSessionStore } from "../src/core/durable-session-store.js";
|
import { DurableSessionStore } from "../src/core/durable-session-store.js";
|
||||||
import { ProposalStore } from "../src/core/proposal-store.js";
|
import { ProposalStore } from "../src/core/proposal-store.js";
|
||||||
@@ -13,6 +13,7 @@ import { BotProfileResolver } from "../src/roles/role-registry.js";
|
|||||||
|
|
||||||
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
|
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
|
||||||
const CHAT_KEY = "webhook:chat-1";
|
const CHAT_KEY = "webhook:chat-1";
|
||||||
|
const CONVERSATION_KEY = assistantKeyFor(CHAT_KEY, "user-1");
|
||||||
|
|
||||||
interface Harness {
|
interface Harness {
|
||||||
home: string;
|
home: string;
|
||||||
@@ -80,7 +81,7 @@ async function closeHarness(harness: Harness): Promise<void> {
|
|||||||
await fs.promises.rm(harness.workspace, { recursive: true, force: true });
|
await fs.promises.rm(harness.workspace, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = (text: string) => ({ platform: "webhook", chatId: "chat-1", userId: "user-1", text });
|
const request = (text: string, userId = "user-1") => ({ platform: "webhook", chatId: "chat-1", userId, text });
|
||||||
|
|
||||||
async function waitFor(condition: () => boolean, timeoutMs = 8_000): Promise<void> {
|
async function waitFor(condition: () => boolean, timeoutMs = 8_000): Promise<void> {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
@@ -105,7 +106,7 @@ test("create proposal stays proposed until confirm; confirm starts the worker wi
|
|||||||
assert.equal(proposal.status, "proposed");
|
assert.equal(proposal.status, "proposed");
|
||||||
assert.equal(proposal.ownerChatKey, CHAT_KEY);
|
assert.equal(proposal.ownerChatKey, CHAT_KEY);
|
||||||
assert.equal(readLog(harness.logFile).filter((entry) => entry.method === "session/new" && entry.cwd === harness.workspace).length, 0);
|
assert.equal(readLog(harness.logFile).filter((entry) => entry.method === "session/new" && entry.cwd === harness.workspace).length, 0);
|
||||||
assert.ok(harness.store.getBinding(CHAT_KEY));
|
assert.ok(harness.store.getBinding(CONVERSATION_KEY));
|
||||||
|
|
||||||
await harness.manager.prompt(request("confirm"));
|
await harness.manager.prompt(request("confirm"));
|
||||||
const working = harness.proposals.get(proposal.id)!;
|
const working = harness.proposals.get(proposal.id)!;
|
||||||
@@ -154,7 +155,7 @@ test("stop does not deadlock while a worker result is waiting to settle", async
|
|||||||
|
|
||||||
fs.writeFileSync(gateFile, "go\n", { mode: 0o600 });
|
fs.writeFileSync(gateFile, "go\n", { mode: 0o600 });
|
||||||
await settleReached;
|
await settleReached;
|
||||||
const stopped = harness.manager.stop("webhook", "chat-1");
|
const stopped = harness.manager.stop("webhook", "chat-1", "user-1");
|
||||||
assert.ok(startSettle);
|
assert.ok(startSettle);
|
||||||
void startSettle();
|
void startSettle();
|
||||||
assert.equal(await stopped, true);
|
assert.equal(await stopped, true);
|
||||||
@@ -259,10 +260,10 @@ test("assistant tool activity fails closed and the next turn starts a clean sess
|
|||||||
const harness = await createHarness();
|
const harness = await createHarness();
|
||||||
try {
|
try {
|
||||||
await assert.rejects(harness.manager.prompt(request("force tool")), /forbidden tool activity/);
|
await assert.rejects(harness.manager.prompt(request("force tool")), /forbidden tool activity/);
|
||||||
assert.equal(harness.store.getBinding(CHAT_KEY), undefined);
|
assert.equal(harness.store.getBinding(CONVERSATION_KEY), undefined);
|
||||||
const ok = await harness.manager.prompt(request("hello"));
|
const ok = await harness.manager.prompt(request("hello"));
|
||||||
assert.equal(ok.text, "ok");
|
assert.equal(ok.text, "ok");
|
||||||
assert.ok(harness.store.getBinding(CHAT_KEY));
|
assert.ok(harness.store.getBinding(CONVERSATION_KEY));
|
||||||
} finally {
|
} finally {
|
||||||
await closeHarness(harness);
|
await closeHarness(harness);
|
||||||
}
|
}
|
||||||
@@ -311,6 +312,117 @@ test("restart recovery kills the persisted worker group and marks the working pr
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("proposals are owned per chat+user: a second user in the same chat cannot confirm, stop, cancel, or list them", async () => {
|
||||||
|
const harness = await createHarness();
|
||||||
|
try {
|
||||||
|
await harness.manager.prompt(request("create proposal: hang", "user-a"));
|
||||||
|
await harness.manager.prompt(request("hello", "user-b"));
|
||||||
|
assert.equal(harness.store.stats().bindings, 2);
|
||||||
|
const proposal = harness.proposals.list()[0]!;
|
||||||
|
assert.equal(proposal.status, "proposed");
|
||||||
|
assert.equal(proposal.requesterUserId, "user-a");
|
||||||
|
|
||||||
|
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-b"), false);
|
||||||
|
await harness.manager.prompt(request("confirm", "user-b"));
|
||||||
|
assert.equal(harness.proposals.get(proposal.id)!.status, "proposed");
|
||||||
|
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-b"), false);
|
||||||
|
await harness.manager.prompt(request("cancel proposal", "user-b"));
|
||||||
|
assert.equal(harness.proposals.get(proposal.id)!.status, "proposed");
|
||||||
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
||||||
|
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-b").length, 0);
|
||||||
|
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-a").length, 1);
|
||||||
|
|
||||||
|
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-a"), true);
|
||||||
|
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
|
||||||
|
|
||||||
|
await harness.manager.prompt(request("hello again", "user-b"));
|
||||||
|
const bPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello again"));
|
||||||
|
assert.ok(bPrompt?.text);
|
||||||
|
assert.match(bPrompt.text!, /details hidden/);
|
||||||
|
assert.doesNotMatch(bPrompt.text!, /Test proposal/);
|
||||||
|
|
||||||
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
||||||
|
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
|
||||||
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
|
||||||
|
assert.equal(harness.proposals.get(proposal.id)!.status, "cancelled");
|
||||||
|
} finally {
|
||||||
|
await closeHarness(harness);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("start_next only starts the requesting user's own queued proposal", async () => {
|
||||||
|
const harness = await createHarness();
|
||||||
|
try {
|
||||||
|
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
|
||||||
|
const first = harness.proposals.list()[0]!;
|
||||||
|
await harness.manager.prompt(request("confirm", "user-a"));
|
||||||
|
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
|
||||||
|
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
|
||||||
|
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
|
||||||
|
await harness.manager.prompt(request("confirm", "user-a"));
|
||||||
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
||||||
|
await harness.manager.prompt(request("confirm", "user-a"));
|
||||||
|
assert.equal(harness.proposals.get(first.id)!.status, "completed");
|
||||||
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
||||||
|
|
||||||
|
await harness.manager.prompt(request("start next", "user-b"));
|
||||||
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
||||||
|
await harness.manager.prompt(request("start next", "user-a"));
|
||||||
|
assert.equal(harness.proposals.get(second.id)!.status, "working");
|
||||||
|
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
|
||||||
|
} finally {
|
||||||
|
await closeHarness(harness);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stop settles the latest awaiting proposal (success completes, failure fails, step cancels) without auto-starting the next", async () => {
|
||||||
|
const harness = await createHarness();
|
||||||
|
try {
|
||||||
|
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
|
||||||
|
const first = harness.proposals.list()[0]!;
|
||||||
|
await harness.manager.prompt(request("confirm", "user-a"));
|
||||||
|
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
|
||||||
|
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "success");
|
||||||
|
|
||||||
|
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
|
||||||
|
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
|
||||||
|
await harness.manager.prompt(request("confirm", "user-a"));
|
||||||
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
||||||
|
|
||||||
|
await harness.manager.prompt(request("stop", "user-a"));
|
||||||
|
assert.equal(harness.proposals.get(first.id)!.status, "completed");
|
||||||
|
assert.ok(harness.proposals.get(first.id)!.finishedAt);
|
||||||
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
||||||
|
|
||||||
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
||||||
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
||||||
|
|
||||||
|
await harness.manager.prompt(request("start next", "user-a"));
|
||||||
|
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
|
||||||
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
|
||||||
|
assert.equal(harness.proposals.get(second.id)!.status, "completed");
|
||||||
|
|
||||||
|
await harness.manager.prompt(request("create proposal: fail", "user-a"));
|
||||||
|
const third = harness.proposals.list().find((proposal) => proposal.id !== first.id && proposal.id !== second.id)!;
|
||||||
|
await harness.manager.prompt(request("confirm", "user-a"));
|
||||||
|
await waitFor(() => harness.proposals.get(third.id)!.status === "awaiting_user_confirmation");
|
||||||
|
assert.equal(harness.proposals.get(third.id)!.pending?.kind, "failure");
|
||||||
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
|
||||||
|
assert.equal(harness.proposals.get(third.id)!.status, "failed");
|
||||||
|
|
||||||
|
await harness.manager.prompt(request("create proposal: ask", "user-a"));
|
||||||
|
const fourth = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
|
||||||
|
await harness.manager.prompt(request("confirm", "user-a"));
|
||||||
|
await waitFor(() => harness.proposals.get(fourth.id)!.status === "awaiting_user_confirmation");
|
||||||
|
assert.equal(harness.proposals.get(fourth.id)!.pending?.kind, "step");
|
||||||
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
|
||||||
|
assert.equal(harness.proposals.get(fourth.id)!.status, "cancelled");
|
||||||
|
assert.equal(harness.manager.status("webhook", "chat-1", "user-a").workerRunning, false);
|
||||||
|
} finally {
|
||||||
|
await closeHarness(harness);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("envelope parsers accept valid tails and reject invalid ones", () => {
|
test("envelope parsers accept valid tails and reject invalid ones", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"stop"}]}</GORI_ASSISTANT_ACTION_V1>`),
|
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"stop"}]}</GORI_ASSISTANT_ACTION_V1>`),
|
||||||
|
|||||||
+13
-5
@@ -37,6 +37,7 @@ class FakeRuntime implements ConversationRuntime {
|
|||||||
confirmResult = true;
|
confirmResult = true;
|
||||||
stopResult = true;
|
stopResult = true;
|
||||||
proposals: Proposal[] = [];
|
proposals: Proposal[] = [];
|
||||||
|
commandUsers: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
async prompt(request: ConversationRequest) {
|
async prompt(request: ConversationRequest) {
|
||||||
return new Promise<{ text: string; botId: string; agentId: string }>((resolve, reject) => {
|
return new Promise<{ text: string; botId: string; agentId: string }>((resolve, reject) => {
|
||||||
@@ -47,12 +48,13 @@ class FakeRuntime implements ConversationRuntime {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async cancel() { this.cancelled++; return this.cancelResult; }
|
async cancel(_platform: string, _chatId: string, userId: string) { this.cancelled++; this.commandUsers.cancel = userId; return this.cancelResult; }
|
||||||
async confirm() { this.confirmed++; return this.confirmResult; }
|
async confirm(_platform: string, _chatId: string, userId: string) { this.confirmed++; this.commandUsers.confirm = userId; return this.confirmResult; }
|
||||||
async stop() { this.stopped++; return this.stopResult; }
|
async stop(_platform: string, _chatId: string, userId: string) { this.stopped++; this.commandUsers.stop = userId; return this.stopResult; }
|
||||||
listProposals() { this.listed++; return this.proposals; }
|
listProposals(_platform: string, _chatId: string, userId: string) { this.listed++; this.commandUsers.list = userId; return this.proposals; }
|
||||||
async reset() {}
|
async reset() {}
|
||||||
status() {
|
status(_platform?: string, _chatId?: string, userId?: string) {
|
||||||
|
this.commandUsers.status = userId;
|
||||||
return {
|
return {
|
||||||
bot: "test-bot", agent: "kimi", workspace: "/tmp",
|
bot: "test-bot", agent: "kimi", workspace: "/tmp",
|
||||||
running: this.turns.length > 0, phase: "processing"
|
running: this.turns.length > 0, phase: "processing"
|
||||||
@@ -228,6 +230,7 @@ test("status and help reach the runtime or reply directly", async () => {
|
|||||||
|
|
||||||
const status = await gateway.receive(message("/status"), adapter, { synchronous: true });
|
const status = await gateway.receive(message("/status"), adapter, { synchronous: true });
|
||||||
assert.match(status.reply || "", /running=true/);
|
assert.match(status.reply || "", /running=true/);
|
||||||
|
assert.equal(runtime.commandUsers.status, "user");
|
||||||
const help = await gateway.receive(message("/help"), adapter, { synchronous: true });
|
const help = await gateway.receive(message("/help"), adapter, { synchronous: true });
|
||||||
assert.match(help.reply || "", /自然语言/);
|
assert.match(help.reply || "", /自然语言/);
|
||||||
assert.match(help.reply || "", /\/confirm/);
|
assert.match(help.reply || "", /\/confirm/);
|
||||||
@@ -262,6 +265,11 @@ test("list, confirm, stop, and cancel forward to the runtime with the chat ident
|
|||||||
assert.equal(runtime.cancelled, 1);
|
assert.equal(runtime.cancelled, 1);
|
||||||
assert.equal(cancel.reply, "已取消最近的提案。");
|
assert.equal(cancel.reply, "已取消最近的提案。");
|
||||||
|
|
||||||
|
assert.equal(runtime.commandUsers.list, "user");
|
||||||
|
assert.equal(runtime.commandUsers.confirm, "user");
|
||||||
|
assert.equal(runtime.commandUsers.stop, "user");
|
||||||
|
assert.equal(runtime.commandUsers.cancel, "user");
|
||||||
|
|
||||||
runtime.confirmResult = false;
|
runtime.confirmResult = false;
|
||||||
runtime.stopResult = false;
|
runtime.stopResult = false;
|
||||||
runtime.cancelResult = false;
|
runtime.cancelResult = false;
|
||||||
|
|||||||
+14
-3
@@ -12,14 +12,25 @@ test("normalizes GROUP and C2C author openid while webhook ACK remains immediate
|
|||||||
const received: any[] = [];
|
const received: any[] = [];
|
||||||
const gateway = { receive: async (message: unknown) => { received.push(message); throw new Error("ACP failed"); } };
|
const gateway = { receive: async (message: unknown) => { received.push(message); throw new Error("ACP failed"); } };
|
||||||
const adapter = new QqAdapter(config, gateway as never);
|
const adapter = new QqAdapter(config, gateway as never);
|
||||||
const group = await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m1", group_openid: "g1", author: { user_openid: "u1" }, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never });
|
const group = await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m1", group_openid: "g1", author: { user_openid: "u1", member_openid: "mem1" }, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never });
|
||||||
const c2c = await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m2", author: { user_openid: "u2" }, content: "hello" } }, headers: {}, query: {}, req: {} as never });
|
const c2c = await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m2", author: { user_openid: "u2", member_openid: "mem2" }, content: "hello" } }, headers: {}, query: {}, req: {} as never });
|
||||||
assert.deepEqual(group.body, { op: 12 }); assert.deepEqual(c2c.body, { op: 12 });
|
assert.deepEqual(group.body, { op: 12 }); assert.deepEqual(c2c.body, { op: 12 });
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
assert.equal(received[0].chatId, "group:g1"); assert.equal(received[0].userId, "u1");
|
assert.equal(received[0].chatId, "group:g1"); assert.equal(received[0].userId, "mem1");
|
||||||
assert.equal(received[1].chatId, "user:u2"); assert.equal(received[1].userId, "u2");
|
assert.equal(received[1].chatId, "user:u2"); assert.equal(received[1].userId, "u2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("group messages without member_openid fall back to user_openid", async () => {
|
||||||
|
const received: any[] = [];
|
||||||
|
const gateway = { receive: async (message: unknown) => { received.push(message); } };
|
||||||
|
const adapter = new QqAdapter(config, gateway as never);
|
||||||
|
await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m3", group_openid: "g1", author: { user_openid: "u3" }, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never });
|
||||||
|
await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m4", group_openid: "g1", member_openid: "mem4", author: {}, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(received[0].userId, "u3");
|
||||||
|
assert.equal(received[1].userId, "mem4");
|
||||||
|
});
|
||||||
|
|
||||||
test("sendMessage uses C2C endpoint and forwards reply sequences as msg_seq", async () => {
|
test("sendMessage uses C2C endpoint and forwards reply sequences as msg_seq", async () => {
|
||||||
const original = globalThis.fetch; const urls: string[] = []; const bodies: Array<Record<string, unknown>> = [];
|
const original = globalThis.fetch; const urls: string[] = []; const bodies: Array<Record<string, unknown>> = [];
|
||||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user