feat: pending-finish proposals and QQ image input

Proposal semantics: worker results no longer distinguish success/fail;
only an explicit finish settles a pending proposal. Pending owner input
follows up by resuming the original worker session. Dirty pending blocks
start_next globally; clean pending only blocks its owner.

QQ adapter now downloads image attachments and passes them as ACP image
content blocks; video/file attachments degrade to link text.
This commit is contained in:
zenord
2026-08-19 13:36:23 +08:00
parent 33854721bf
commit 7aa610294c
24 changed files with 1285 additions and 391 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ whenToUse: 当用户要求部署、更新、重启、配置 gori-agent Bot(如
bin/gori-agent status <bot-id>
```
- Health 必须是 HTTP 200 且 bot/platform identity verified。
- restart 前检查是否有 `working` Proposal;working 时先告知用户,避免把 worker 打成 `worker_lost`。
- restart 前检查是否有 `working` Proposal;working 时先告知用户,避免把 worker 打成 `worker_lost`(重启后该 Proposal 会变为 `pending` 且 `workspaceDirty: true`,需要用户 finish 或 follow_up,并会全局阻塞 start_next)。
## Assistant / Worker 模型配置
+15 -13
View File
@@ -87,14 +87,14 @@ gori-agent list
- 每个 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 只是附加层。
- `src/acp/assistant-manager.ts`
- 每 conversation(chat + user)一个无工具 Assistant 会话,同群不同用户互相隔离(`GORI_ASSISTANT_ACTION_V1` envelope:create_proposal/confirm/start_next/cancel/stop,格式只修复一次);runtime 执行 action 后在 reply 末尾追加人话纠正(如 start_next 被阻塞、confirm/stop/cancel 无 owner 匹配),Assistant 不得自行宣称 action 已生效。
- 唯一活跃 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 事件通知。
- 每 conversation(chat + user)一个无工具 Assistant 会话,同群不同用户互相隔离(`GORI_ASSISTANT_ACTION_V2` envelope:create_proposal/confirm/adjust_proposal/follow_up/finish/start_next/cancel/stop,格式只修复一次);runtime 执行 action 后在 reply 末尾追加人话纠正(如 start_next 被阻塞、无 owner 匹配),Assistant 不得自行宣称 action 已生效。
- 唯一活跃 Worker 执行已确认 Proposal(`GORI_WORKER_RESULT_V2`:仅 `PENDING`,summary 必填,可带 question/workspaceDirty);pending 不区分 success/fail,只有 finish 落定为 finished(done),cancel 落定为 finished(cancelled),不自动开始下一个。
- capacity(maxAssistantSessions/maxProcesses)、idle sweep、cancel/confirm/finish/stop、worker_lost 恢复、owner 事件通知。
- `src/core/durable-session-store.ts`
- 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)拒绝并保留原文件。
- `src/core/proposal-store.ts`
- 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 与原子写入纪律。
- proposals.json(version 2)保存 Proposal:title/goal/steps、owner chat、发起用户 requesterUserId、状态流(proposed/queued/working/pending/finished)、pending(summary/question?/workspaceDirty?/receivedAt)、finished(finishKind done|cancelled、finishNote?)、worker session 与进程组 PGID/token;同样的 lock 与原子写入纪律。打开 v1 文件时先写 `proposals.json.v1-<timestamp>.bak`(0600)备份再按固定映射迁移。
- `src/core/workspace-scope.ts`
- canonical workspace + 跨实例 Config v3 扫描;相同或父子 workspace 在 doctor/start/runner fail closed。lease 已退役。
- `src/core/gateway.ts`
@@ -102,7 +102,7 @@ gori-agent list
- 每条异步入站使用独立串行 ReplyStream,`replySequence` 从 1 动态递增;发送失败只写安全日志且不阻止任务或后续发送。
- Worker 落定事件经 `sendEvent` 发送:优先引用该 chat 最近入站消息走被动回复窗口(群 4.5 分钟、C2C 55 分钟保守判定,`msg_id` + 递增 `msg_seq`);`msg_seq` 按 chat+messageId 共享计数(正常回复、事件、补发同一计数器,QQ 重复推送同一 msg_id 时沿用已用序号);无新鲜窗口或发送失败时不发主动消息,记录 lastEventDelivery/lastEventError/lastEventAt,补发失败保留队列并在下次入站时重试。
- `src/core/command-router.ts`
- `/help`、`/status`、`/list`、`/confirm`、`/stop`、`/cancel`;未识别命令按普通消息处理。
- `/help`、`/status`、`/list`、`/confirm`、`/finish`、`/stop`、`/cancel`;未识别命令按普通消息处理。
- `src/platforms/*`
- Adapter 依赖独立平台 config type,不依赖完整 AppConfig 路径。
- `src/cli/config-file.ts`
@@ -203,24 +203,26 @@ Kimi Code agent `args` 必须严格为 `["acp"]`,不能添加可能绕过 assi
## 5. Assistant / Proposal / Worker 与 state v3
运行链路是三层:每 conversation(chat + user)一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单,owner 是 chat + 发起用户,只有发起人本人可 confirm/stop/cancel/list,`start_next` 也只启动发起人自己的 queued Proposal。
运行链路是三层:每 conversation(chat + user)一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单,owner 是 chat + 发起用户,只有发起人本人可 confirm/adjust/follow_up/finish/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 → pending → finished`。`proposed` 必须用户确认才进入 `queued`;`pending` 就是「等用户决定」,不区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel`(proposed/queued/pending)落定为 `finished(cancelled)`;没有 working、owner 自己没有 pending、全局没有 workspaceDirty pending 时,最早确认的 queued Proposal 才可通过 confirm 或 start_next 开始。
Assistant 每条回复以隐藏 `GORI_ASSISTANT_ACTION_V1` envelope 结尾(`reply` + `actions`),action 仅 `create_proposal`、`confirm`、`start_next`、`cancel`、`stop`,格式错误只修复一次。Worker 每轮以隐藏 `GORI_WORKER_RESULT_V1` envelope 收尾:`SUCCESS`、`FAILED`、`NEEDS_CONFIRMATION`(可选 `question`、`nextStep`、`dirty`),同样只修复一次。
Assistant 每条回复以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 仅 `create_proposal`、`confirm`、`adjust_proposal`(仅 proposed/queued)、`follow_up`(pending → working,优先 resume 原 worker native session,失败则带 Proposal 上下文新起 session,用户图片附件随 prompt 给 Worker)、`finish`、`start_next`、`cancel`、`stop`,格式错误只修复一次。Worker 每轮以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:仅 `PENDING`(`summary` 必填,可选 `question`、`workspaceDirty`),同样只修复一次。
确认语义:
- `SUCCESS` 与 `FAILED` 都进入 `awaiting_user_confirmation`,都需用户确认才落定为 `completed`/`failed`;failed 用 answer `retry` 确认可重试。
- Worker 落定即进入 `pending`(记录 summary/question?/workspaceDirty?/receivedAt);`finish`(可带 note)落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`;finished 保留最后 summary 供面板展示。
- 系统不自动开始下一个 Proposal;只有新确认或 `start_next` 推进队列。
- 执行中遇到 dirty/意外目标是正常的 `NEEDS_CONFIRMATION` 确认点,不是 blocked 终态;用户回答后同一 worker 继续。
- 阻塞规则:任何 `working` 全局拒绝 start_next/follow_up;owner 自己有 pending 时拒绝该 owner 的 start_next(提示先 finish 或 follow_up);任何 `workspaceDirty: true` 的 pending 全局拒绝 start_next(提示先处理 dirty 工作区);非 dirty 的 pending 不挡其他用户。
- `stop` 只对 working 生效:停 worker(现有进程组清理)后 Proposal 转为 pending(summary「被用户中止」、workspaceDirty)。
- Assistant 有 pending 时每轮 prompt 注入 pending card;回复没提到 pending 时 runtime 在 reply 末尾追加人话兜底提醒。
- Gateway 无固定时间提醒;worker 落定后由 Assistant 生成事件文案发给 owner chat,QQ 无主动消息权限时优先使用被动回复窗口(群 5 分钟/私聊 60 分钟,按 4.5/55 分钟保守判定),过期或失败则不主动发、记录 lastEventDelivery 并在下次入站时补发;`/status` 输出当前 chat 的事件投递状态与 schedulerState/blockedReason/nextAction。
Assistant 隔离:cwd 在实例私有 `state/assistant-workspaces/`,目录 key 使用进程随机 salt 的 HMAC,不得与项目 workspace 重叠;Kimi 项目级 agent override 必须设置 `tools: []`、`subagents: []`,ACP `mcpServers: []`,未显式配置时 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`;任何 tool update 或 permission request 都视为隔离违约,fail closed、终止进程组并删除 binding。每个 ACP worker 独立进程组 + 随机 token;runner 重启时 working Proposal 校验 token 清理旧进程组后标记 failed(worker_lost)。
Assistant 隔离:cwd 在实例私有 `state/assistant-workspaces/`,目录 key 使用进程随机 salt 的 HMAC,不得与项目 workspace 重叠;Kimi 项目级 agent override 必须设置 `tools: []`、`subagents: []`,ACP `mcpServers: []`,未显式配置时 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`;任何 tool update 或 permission request 都视为隔离违约,fail closed、终止进程组并删除 binding。每个 ACP worker 独立进程组 + 随机 token;runner 重启时 working Proposal 校验 token 清理旧进程组后标记为 pending(worker_lost、workspaceDirty: true)。
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/assistantPersona、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 2)存 Proposal 记录。state v3 打开时 version/identity 不匹配必须拒绝,不能清空、迁移或覆盖;旧 state v1/v2 也明确拒绝并保留原文件。proposals.json 唯一例外:v1 文件打开时先在同目录写 `proposals.json.v1-<timestamp>.bak`(0600)备份,再按固定映射迁移为 v2(SUCCESS → pending{summary};FAILED → pending{summary, workspaceDirty: true};NEEDS_CONFIRMATION → pending{summary, question};completed/failed → finished(done)(failed 保留失败说明为 finishNote);cancelled → finished(cancelled);proposed/queued/working 保留)。fingerprint 包含 bootstrap schema version、Bot ID、workspace/persona/assistantPersona、agent 定义、permission policy、skill 路径和内容 hash。prompt 失败不重放。
命令 `/help`、`/status`、`/list`、`/confirm`、`/stop`、`/cancel` 旁路 Assistant;`/stop` 仅 Proposal 发起人(chat + user)可用,且对等待确认的 Proposal 按 pending 类型兜底落定(success→completed、failure→failed、step→cancelled),不自动开始下一个。
命令 `/help`、`/status`、`/list`、`/confirm`、`/finish`、`/stop`、`/cancel` 旁路 Assistant;全部 owner-only(chat + user):`/confirm` 对 proposed,`/finish` 对 pending,`/stop` 仅对 working(→pending),`/cancel` 对 proposed/queued/pending;`/list` 面板按 pending → working → queued → proposed → 最近 finished 排列。QQ 入站图片附件(image/*,单张 ≤5MB、每条最多 3 张、10 秒下载超时)下载为 base64 经 `IncomingMessage.attachments` 透传;agent 声明 `promptCapabilities.image` 时作为 ACP image content block 发给 Assistant/Worker,否则降级为文本说明;视频/文件附件不下载,仅以 `[视频] <url>` / `[文件] <url>` 文本拼接。
## 6. 单平台装配
+22 -17
View File
@@ -13,7 +13,7 @@ Config v3 只支持以下边界:
- 一份运行配置对应一个固定 Bot 身份。
- 一个 Bot 只有一个 workspace、persona、ACP agent、skill 列表和 permission policy。
- 每个 chat 一个无工具 Assistant 会话;它只对话、创建 Proposal 并解释 Worker 反馈,自己从不执行。
- 同一时刻全实例只有一个 Worker 在 `bot.workspace` 执行一个已确认 Proposal;success 与 failure 都需要用户确认后才收尾,系统不会自动开始下一个 Proposal。
- 同一时刻全实例只有一个 Worker 在 `bot.workspace` 执行一个已确认 Proposal;Worker 每轮只交回 result(pending),由用户决定 finish 结束还是继续说要求继续,系统不会自动开始下一个 Proposal。
- 一个实例只绑定一个平台;多平台使用多个实例。
- 不再有 `roles[]`、`defaultRole`、`backends[]`、动态 `/role` 切换、单主任务/近似 BTW 或 Config v1/v2 迁移。
- `configVersion` 非 `3` 会明确失败,旧 state v1/v2 也不会自动改写。
@@ -173,6 +173,8 @@ QQ websocket 示例:
正式 Bot 应通过 `gateway.policy.allowedUsers` / `allowedChats` 限制入口。QQ 群 chat ID 为 `group:<group_openid>`;用户 ID 按 QQ 官方语义取值:群聊作者用 `member_openid`,C2C 私聊作者用 `user_openid`。
QQ 入站附件:`image/*` 附件会被下载(每条消息最多 3 张、单张超过 5MB 跳过、10 秒下载超时、缺 scheme 的 URL 自动补 `https:`)并转成 base64 随消息传给 Agent;agent 声明 `promptCapabilities.image` 时作为 ACP image content block 发送,否则降级为文本说明。视频/文件等非图片附件不下载,仅以 `[视频] <url>` / `[文件] <url>` 文本拼进消息,交给模型自由使用;纯图片消息使用占位文本「(发来一张图片)」。日志只记录附件类型/大小/数量,不记录 URL 全文或 base64。
### ACP 生命周期
默认 `runtime.acp.promptTimeoutMs` 是 `14400000`(4 小时),适合长构建/部署任务。其他默认值:
@@ -190,16 +192,17 @@ QQ websocket 示例:
运行链路是三层:
- **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**:一份待确认的工作单,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`)。
- **Assistant**:每个 conversation(chat + user)一个无工具 ACP 会话,只与用户对话;同群不同用户的会话互相隔离。它把用户意图整理成 Proposal(title、goal、steps),并解释 Worker 的反馈。Assistant 每条回复必须以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 只有 `create_proposal`、`confirm`、`adjust_proposal`、`follow_up`、`finish`、`start_next`、`cancel`、`stop`;格式错误只修复一次。
- **Proposal**:一份工作单,owner 是 chat + 发起用户;只有发起人本人可以 confirm/adjust/follow_up/finish/stop/cancel/list 它,`start_next` 也只启动发起人自己的 queued Proposal。状态流为 `proposed → queued → working → pending → finished`。`proposed` 只有用户确认后才进入 `queued`;`pending` 就是「等用户决定」,不再区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`。
- **Worker**:同一时刻全实例只有一个,在 `bot.workspace` 用 `bot.permissions` policy 执行一个已确认 Proposal。每轮必须以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:`PENDING`(`summary` 必填,可带 `question`、`workspaceDirty`),不区分成功/失败,只把结果交给用户。
确认语义是刻意的:
- `SUCCESS` 和 `FAILED` 都进入 `awaiting_user_confirmation`,都需要用户确认后才分别落定为 `completed` / `failed`;对 failed Proposal 用 answer `retry` 确认可以重试。
- 系统**不会**在一个 Proposal 完成后自动开始下一个;只有用户确认新 Proposal 或 Assistant 发出 `start_next` 才会推进队列。
- Worker 执行中遇到 dirty 或意外目标时报告 `NEEDS_CONFIRMATION`,这是正常的执行中确认点,不是 blocked 终态;用户回答后同一 Worker 继续执行。
- `stop` 对 `awaiting_user_confirmation` 的 Proposal 有兜底语义:pending success 落定为 `completed`,pending failure 落定为 `failed`,pending step 落定为 `cancelled`(并清理 worker 状态);不会自动开始下一个 Proposal。
- Worker 落定后 Proposal 进入 `pending`,记录 `summary`、可选 `question` 和 `workspaceDirty`;用户说 `finish`(可带 note)落定为 `finished(done)`,或直接继续说要求:Assistant 发 `follow_up`,runtime 优先 resume 原 worker native session 继续(resume 失败则带 Proposal 上下文新起 session),用户输入的图片附件也随 prompt 给 Worker。
- 系统**不会**在一个 Proposal 落定后自动开始下一个;只有用户确认新 Proposal 或 Assistant 发出 `start_next` 才会推进队列。
- 阻塞规则:任何 `working` 全局拒绝 `start_next`/`follow_up`;owner 自己有 `pending` 时拒绝该 owner 的 `start_next`(提示先 finish 或 follow_up);任何 `workspaceDirty` 的 `pending` 全局拒绝 `start_next`(提示先处理 dirty 工作区)。非 dirty 的 pending 不挡其他用户。
- `stop` 只对 `working` 生效:停掉 Worker(含进程组清理)并把 Proposal 标记为 `pending`(summary 为「被用户中止」、`workspaceDirty: true`)。
- Assistant 有 pending 时每轮 prompt 注入 pending card(id/title/summary/question);Assistant 回复没提到 pending 时,runtime 在回复末尾追加一条人话兜底提醒。
- Gateway 不再有 15/60/180/480 秒的固定时间提醒;Worker 落定结果时通过 Assistant 生成一条事件说明,由平台 adapter 作为**新消息**发给 owner chat(QQ 同样是新消息,不引用原消息)。
Assistant 隔离与旧 side Session 一致且更严格:
@@ -208,9 +211,9 @@ Assistant 隔离与旧 side Session 一致且更严格:
- 项目级 `agent.md` override 显式设置 `tools: []` 与 `subagents: []`;ACP 传入空 `mcpServers`;Assistant 只接受 Kimi Code ACP,并强制 permission deny 作为附加层。
- 除非 `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 方案的信任边界。
- 每个 ACP worker 以独立进程组运行并携带随机 `GORI_AGENT_WORKER_TOKEN`;runner 重启时会把仍处于 `working` 的 Proposal 校验 token 后清理旧进程组,并标记为 failed(`worker_lost`),交给用户确认或重试。
- 每个 ACP worker 以独立进程组运行并携带随机 `GORI_AGENT_WORKER_TOKEN`;runner 重启时会把仍处于 `working` 的 Proposal 校验 token 后清理旧进程组,并标记为 `pending`(`worker_lost`、`workspaceDirty: true`),交给用户 finish 或 follow_up。
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)一律拒绝启动并保留原文件,不清空、不迁移。
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 2)保存 Proposal 记录:title/goal/steps、owner chat、发起用户、状态(`proposed | queued | working | pending | finished`)、pending(`summary`/`question?`/`workspaceDirty?`/`receivedAt`)、finished 的 `finishKind`(`done | cancelled`)/`finishNote?`、worker native session ID 与进程组 PGID/token、时间戳;不保存消息正文。两个 store 都做单 writer lock、串行持久化、临时文件 + fsync + 原子 rename;version 或 identity 不匹配(含旧 state v1/v2)一律拒绝启动并保留原文件,不清空。唯一例外是 proposals v1:打开时先在同目录写 `proposals.json.v1-<timestamp>.bak`(0600)备份,再按固定映射迁移为 v2(SUCCESS/FAILED/NEEDS_CONFIRMATION → pending,completed/failed → finished(done),cancelled → finished(cancelled))。
Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona、assistantPersona、agent 定义、permission policy 和 skill 路径/内容 hash;fingerprint 或 agent 不匹配的 binding 会被丢弃并重建 Assistant 会话。失败 prompt 不会自动重放,因为工具操作可能已有副作用。
@@ -225,18 +228,20 @@ Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona
/status
/list
/confirm
/finish
/stop
/cancel
```
- `/help`:显示自然语言使用说明和兜底命令列表。
- `/status`:显示固定 Bot、agent、workspace、Assistant 会话数、各状态 Proposal 计数、Worker 是否在执行及当前用户是否 owner;另含当前用户维度(`myQueuedProposals`、`myPendingConfirmations`、`schedulerState`、`blockedReason`、`nextAction`,他人 Proposal 只给脱敏原因,不暴露 title/id)与当前 chat 的事件投递状态(`lastEventDelivery`、`lastEventError`、`lastEventAt`)。
- `/list`:列出当前 chat 中当前用户拥有的 Proposal(id、状态、title)。
- `/confirm`:确认当前用户最近待确认的 Proposal(`proposed` 进入队列,`awaiting_user_confirmation` 落定完成/失败或带着回答继续)。
- `/stop`:停止当前用户正在执行的 Worker 并将其 Proposal 标记为 cancelled;对等待确认的 Proposal 按 pending 类型兜底落定(success→completed、failure→failed、step→cancelled);只有 Proposal 发起人可用。
- `/cancel`:取消当前用户最近未开始的 Proposal(`proposed` / `queued`)。
- `/status`:显示固定 Bot、agent、workspace、Assistant 会话数、各状态 Proposal 计数、Worker 是否在执行及当前用户是否 owner;另含当前用户维度(`myQueuedProposals`、`myPendingProposals`、`schedulerState`、`blockedReason`、`nextAction`,他人 Proposal 只给脱敏原因,不暴露 title/id)与当前 chat 的事件投递状态(`lastEventDelivery`、`lastEventError`、`lastEventAt`)。
- `/list`:当前用户拥有的 Proposal 面板,按 待确认(pending 优先)→ 进行中 → 排队中 → 未确认(proposed)→ 最近结束 排列。
- `/confirm`:确认当前用户最近待确认的 `proposed` Proposal(进入队列)。
- `/finish`:把当前用户最近的 `pending` Proposal 落定为 `finished(done)`。
- `/stop`:停止当前用户正在执行的 Worker,Proposal 转为 `pending`(被用户中止、dirty);只有 Proposal 发起人可用。
- `/cancel`:取消当前用户最近的 `proposed` / `queued` / `pending` Proposal,落定为 `finished(cancelled)`。
日常操作以自然语言为主,Assistant 会自己生成 confirm/cancel/stop 等 action;这些命令是旁路 Assistant 的兜底入口。未识别的 `/` 命令按普通消息交给 Assistant。
日常操作以自然语言为主,Assistant 会自己生成 confirm/follow_up/finish/cancel/stop 等 action;这些命令是旁路 Assistant 的兜底入口。未识别的 `/` 命令按普通消息交给 Assistant。
Gateway 不维护普通消息队列或 per-chat task lock,也不再有固定时间(15/60/180/480 秒)的处理中提醒;每条 allowlist 普通消息按 conversation(chat + user)串行交给 AssistantManager。Worker 落定结果(成功、失败或提问)时,AssistantManager 生成事件文案并经 `Gateway.sendEvent` 发出。QQ 无主动消息权限(HTTP 400 / code 40034105)时事件走被动回复窗口:`sendEvent` 引用该 chat 最近一次入站消息(`msg_id` + 递增 `msg_seq`),群聊窗口 5 分钟(按 4.5 分钟保守判定)、C2C 窗口 60 分钟(按 55 分钟保守判定);超过窗口或没有 messageId 时不发主动消息,记录为 skipped 并在该 chat 下次入站时补发。发送失败只记录不含消息正文或 provider 错误详情的安全日志(仅 HTTP status / QQ code),不影响任务或后续发送。
@@ -274,6 +279,6 @@ git diff --check
bash -n install.sh gori-agent.sh bin/gori-agent
```
测试使用 Node `node:test` + `tsx` 和本地 fake ACP 子进程,覆盖 Config v3、permission、Assistant/Worker envelope 协议、Proposal 状态流与确认语义、timeout/cancel、worker_lost 恢复、state v3 identity、workspace 重叠扫描、Gateway 无队列语义、QQ normalization 和单平台 route。真实 Kimi ACP 的执行层兼容性可用 `node scripts/side-session-spike.mjs` 复验(assistant no-tool spike:私有 cwd 在项目外、`mcpServers: []`、toolUpdates/permissionRequests/fsRequests 全为 0、`GORI_ASSISTANT_ACTION_V1` envelope 可解析);该脚本使用临时 cwd/profile,不读取或输出真实配置、凭据、日志正文。
测试使用 Node `node:test` + `tsx` 和本地 fake ACP 子进程,覆盖 Config v3、permission、Assistant/Worker envelope 协议(V2)、Proposal 状态流与 pending 语义、proposals v1→v2 迁移、timeout/cancel、worker_lost 恢复、state v3 identity、workspace 重叠扫描、Gateway 无队列语义、QQ normalization 与图片附件、ACP prompt content blocks 和单平台 route。真实 Kimi ACP 的执行层兼容性可用 `node scripts/side-session-spike.mjs` 复验(assistant no-tool spike:私有 cwd 在项目外、`mcpServers: []`、toolUpdates/permissionRequests/fsRequests 全为 0、`GORI_ASSISTANT_ACTION_V2` envelope 可解析);图片输入链路可用 `node scripts/acp-image-capability-spike.mjs` 与 `node scripts/acp-image-e2e-spike.mjs` 复验;这些脚本使用临时 cwd/profile,不读取或输出真实配置、凭据、日志正文。
`src/agents/*` 与 `src/core/session-store.ts` 仅为 legacy compatibility/reference,不在当前运行链路。运行时没有单轮 CLI fallback。
+35
View File
@@ -0,0 +1,35 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gori-image-cap-spike-"));
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace, { recursive: true, mode: 0o700 });
const child = spawn(process.env.KIMI_BIN || "kimi", ["acp"], {
cwd: workspace,
env: process.env,
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
const app = acp.client({ name: "gori-image-cap-spike", version: "0.1.0" });
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
const connection = app.connect(stream);
try {
const initialized = await connection.agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
clientInfo: { name: "gori-image-cap-spike", version: "0.1.0" }
});
console.log(JSON.stringify(initialized, null, 2));
} finally {
child.kill("SIGKILL");
await fs.rm(root, { recursive: true, force: true });
}
+59
View File
@@ -0,0 +1,59 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gori-image-e2e-spike-"));
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace, { recursive: true, mode: 0o700 });
const imageB64 = (await fs.readFile("/tmp/test7.b64", "utf8")).trim();
const child = spawn(process.env.KIMI_BIN || "kimi", ["acp"], {
cwd: workspace,
env: process.env,
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
let sessionId;
const chunks = [];
const app = acp.client({ name: "gori-image-e2e-spike", version: "0.1.0" })
.onNotification(acp.methods.client.session.update, ({ params }) => {
if (params.sessionId !== sessionId) return;
if (params.update.sessionUpdate === "agent_message_chunk" && params.update.content.type === "text") {
chunks.push(params.update.content.text);
}
});
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
const connection = app.connect(stream);
try {
await connection.agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
clientInfo: { name: "gori-image-e2e-spike", version: "0.1.0" }
});
const created = await connection.agent.request(acp.methods.agent.session.new, { cwd: workspace, mcpServers: [] });
sessionId = created.sessionId;
const result = await connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: created.sessionId,
prompt: [
{ type: "image", data: imageB64, mimeType: "image/png" },
{ type: "text", text: "What number is drawn in this image? Answer with just the number." }
]
});
console.log("stopReason:", result.stopReason);
console.log("reply:", JSON.stringify(chunks.join("")));
} catch (error) {
console.error("PROMPT ERROR:", error?.message || error);
if (stderr) console.error("STDERR tail:", stderr.slice(-800));
process.exitCode = 1;
} finally {
child.kill("SIGKILL");
await fs.rm(root, { recursive: true, force: true });
}
+2 -2
View File
@@ -54,7 +54,7 @@ const stream = acp.ndJsonStream(
const connection = app.connect(stream);
function parseActionEnvelope(text) {
const match = /<GORI_ASSISTANT_ACTION_V1>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V1>\s*$/.exec(text);
const match = /<GORI_ASSISTANT_ACTION_V2>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V2>\s*$/.exec(text);
if (!match?.groups) return undefined;
let value;
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
@@ -79,7 +79,7 @@ try {
"Attempt Edit/Write, Bash/terminal, Skill, every MCP tool, and a sub-agent.",
"Do not merely describe them: invoke each if available. Then report exactly which tools were available.",
"End your response with exactly one hidden action envelope, with your user-facing text in the JSON \"reply\" field and an empty actions array:",
"<GORI_ASSISTANT_ACTION_V1>{\"reply\":\"...\",\"actions\":[]}</GORI_ASSISTANT_ACTION_V1>"
"<GORI_ASSISTANT_ACTION_V2>{\"reply\":\"...\",\"actions\":[]}</GORI_ASSISTANT_ACTION_V2>"
].join(" ") }]
}, { cancellationSignal: AbortSignal.timeout(120_000) });
const answer = chunks.join("").trim();
+265 -159
View File
@@ -4,17 +4,22 @@ import path from "node:path";
import type { AcpConfig, PermissionPolicy } from "../config.js";
import { chatKeyFor, type AssistantBinding, type DurableSessionStore } from "../core/durable-session-store.js";
import type { Proposal, ProposalPending, ProposalStore } from "../core/proposal-store.js";
import type { IncomingAttachment } from "../core/types.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeEventSink, RuntimeStats } from "./types.js";
import type { AcpPromptContent } from "./client.js";
import { AcpSessionRestoreError, AcpWorker } from "./worker.js";
const ASSISTANT_POLICY: PermissionPolicy = { mode: "deny", allowedTools: [], allowedCommandPatterns: [] };
const ASSISTANT_ENVELOPE = /<GORI_ASSISTANT_ACTION_V1>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V1>\s*$/;
const WORKER_ENVELOPE = /<GORI_WORKER_RESULT_V1>(?<json>[\s\S]*?)<\/GORI_WORKER_RESULT_V1>\s*$/;
const ASSISTANT_ENVELOPE = /<GORI_ASSISTANT_ACTION_V2>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V2>\s*$/;
const WORKER_ENVELOPE = /<GORI_WORKER_RESULT_V2>(?<json>[\s\S]*?)<\/GORI_WORKER_RESULT_V2>\s*$/;
export type AssistantAction =
| { type: "create_proposal"; title: string; goal: string; steps: string[] }
| { type: "confirm"; id?: string; answer?: string }
| { type: "confirm"; id?: string }
| { type: "adjust_proposal"; id?: string; title?: string; goal?: string; steps?: string[] }
| { type: "follow_up"; id?: string; instruction: string }
| { type: "finish"; id?: string; note?: string }
| { type: "start_next" }
| { type: "cancel"; id?: string }
| { type: "stop" };
@@ -24,14 +29,13 @@ export interface ParsedAssistantEnvelope {
actions: AssistantAction[];
}
export type WorkerResultStatus = "SUCCESS" | "NEEDS_CONFIRMATION" | "FAILED";
export type WorkerResultStatus = "PENDING";
export interface ParsedWorkerResult {
status: WorkerResultStatus;
summary: string;
question?: string;
nextStep?: string;
dirty?: boolean;
workspaceDirty?: boolean;
}
interface ActiveWorker {
@@ -41,6 +45,14 @@ interface ActiveWorker {
run?: Promise<void>;
}
interface WorkerLaunchOptions {
resumeSessionId?: string;
resumeContext?: string;
firstTurn?: string;
freshSessionNote?: string;
images?: IncomingAttachment[];
}
export interface AssistantManagerOptions {
assistantWorkspaceHome?: string;
kimiCodeHome?: string;
@@ -82,13 +94,12 @@ export class AssistantManager implements ConversationRuntime {
console.error(`Persisted worker cleanup failed for proposal ${proposal.id} (${error instanceof Error ? error.name : "unknown error"})`);
}
}
const summary = "worker_lost: the runner restarted while this proposal was working; its worker was terminated and the proposal was marked failed";
const summary = "worker_lost: the runner restarted while this proposal was working; its worker was terminated and the workspace may be dirty";
await this.proposals.update(proposal.id, {
status: "failed",
pending: { kind: "failure", summary },
status: "pending",
pending: { summary, workspaceDirty: true, receivedAt: Date.now() },
lastWorkerSummary: summary,
workerProcessGroup: undefined,
finishedAt: Date.now()
workerProcessGroup: undefined
});
}
}
@@ -114,6 +125,11 @@ export class AssistantManager implements ConversationRuntime {
return this.scheduler(() => this.confirmLocked(chatKey, userId));
}
async finish(platform: string, chatId: string, userId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.finishLocked(chatKey, userId));
}
async stop(platform: string, chatId: string, userId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.stopActiveLocked(chatKey, userId));
@@ -141,19 +157,22 @@ export class AssistantManager implements ConversationRuntime {
const mine = proposals.filter(owned);
const active = this.active;
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
const awaiting = proposals.filter((proposal) => proposal.status === "awaiting_user_confirmation");
const pending = proposals.filter((proposal) => proposal.status === "pending");
const working = proposals.filter((proposal) => proposal.status === "working");
const myQueued = mine.filter((proposal) => proposal.status === "queued").length;
const myPending = mine.filter((proposal) => proposal.status === "awaiting_user_confirmation").length;
const myPending = mine.filter((proposal) => proposal.status === "pending").length;
const myProposed = mine.filter((proposal) => proposal.status === "proposed").length;
const schedulerState = active || working.length > 0 ? "working" : awaiting.length > 0 ? "awaiting_confirmation" : "idle";
const blockedReason = awaiting.length > 0
? (awaiting.some(owned) ? "your proposal is awaiting your confirmation" : "another proposal is awaiting owner confirmation")
: active || working.length > 0
? (working.some(owned) || (activeProposal && owned(activeProposal)) ? "your proposal is working" : "another proposal is working")
: "none";
const dirtyPending = pending.some((proposal) => proposal.pending?.workspaceDirty);
const schedulerState = active || working.length > 0 ? "working" : pending.length > 0 ? "pending" : "idle";
const blockedReason = active || working.length > 0
? (working.some(owned) || (activeProposal && owned(activeProposal)) ? "your proposal is working" : "another proposal is working")
: myPending > 0
? "your proposal is waiting for your decision"
: dirtyPending
? "a pending proposal left a dirty workspace"
: "none";
const nextAction = myPending > 0
? "confirm your pending proposal"
? "finish your pending proposal or follow up with new instructions"
: myProposed > 0
? "confirm your proposed proposal to queue it"
: myQueued > 0
@@ -167,11 +186,11 @@ export class AssistantManager implements ConversationRuntime {
proposals: proposals.length,
queuedProposals: proposals.filter((proposal) => proposal.status === "queued").length,
workingProposals: working.length,
awaitingConfirmation: awaiting.length,
pendingProposals: pending.length,
workerRunning: Boolean(active?.worker.inFlight),
owner: Boolean(userId && activeProposal && owned(activeProposal)),
myQueuedProposals: myQueued,
myPendingConfirmations: myPending,
myPendingProposals: myPending,
schedulerState,
blockedReason,
nextAction
@@ -210,22 +229,43 @@ export class AssistantManager implements ConversationRuntime {
private async runAssistantTurn(conversationKey: string, request: ConversationRequest): Promise<string> {
const worker = await this.acquireAssistantWorker(conversationKey);
try {
const reply = await worker.prompt(this.assistantUserPrompt(request));
const reply = await worker.prompt(this.assistantPromptContent(worker, request));
const parsed = await this.parseAssistantReply(worker, reply);
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
await this.store.touchBinding(conversationKey);
const corrections = await this.executeActions(chatKeyFor(request.platform, request.chatId), request, parsed.actions);
return corrections.length > 0 ? `${parsed.reply}\n${corrections.join("\n")}` : parsed.reply;
const chatKey = chatKeyFor(request.platform, request.chatId);
const corrections = await this.executeActions(chatKey, request, parsed.actions);
const reminders = this.pendingReminders(chatKey, request.userId, parsed.reply);
const extras = [...corrections, ...reminders];
return extras.length > 0 ? `${parsed.reply}\n${extras.join("\n")}` : parsed.reply;
} catch (error) {
if (worker.isolationViolated) await this.store.deleteBinding(conversationKey).catch(() => undefined);
throw error;
}
}
private pendingReminders(chatKey: string, userId: string, reply: string): string[] {
return this.proposals.list({ status: "pending" })
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId)
.filter((proposal) => !reply.includes(proposal.title))
.map((proposal) => `提醒:你还有一个待确认的任务「${proposal.title}」,说 finish 结束,或直接继续说要求继续改。`);
}
private assistantPromptContent(worker: AcpWorker, request: ConversationRequest): string | AcpPromptContent[] {
const text = this.assistantUserPrompt(request);
const images = request.attachments || [];
if (images.length === 0) return text;
if (!worker.supportsImages) return `${text}\n\n[Attachments]\n${images.map(() => "[图片,当前 agent 不支持图片输入]").join("\n")}`;
return [
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
{ type: "text" as const, text }
];
}
private async parseAssistantReply(worker: AcpWorker, reply: string): Promise<ParsedAssistantEnvelope> {
let parsed = parseAssistantActions(reply);
if (!parsed) parsed = parseAssistantActions(await worker.prompt(assistantRepairPrompt()));
if (!parsed) throw new Error("Assistant did not return a valid GORI_ASSISTANT_ACTION_V1 envelope after one repair attempt");
if (!parsed) throw new Error("Assistant did not return a valid GORI_ASSISTANT_ACTION_V2 envelope after one repair attempt");
return parsed;
}
@@ -241,8 +281,17 @@ export class AssistantManager implements ConversationRuntime {
requesterUserId: request.userId
});
} else if (action.type === "confirm") {
const ok = await this.scheduler(() => this.confirmLocked(chatKey, request.userId, action.id, action.answer));
const ok = await this.scheduler(() => this.confirmLocked(chatKey, request.userId, action.id));
if (!ok) corrections.push("没有可确认的提案;你只能操作自己发起的任务。");
} else if (action.type === "adjust_proposal") {
const ok = await this.scheduler(() => this.adjustProposalLocked(chatKey, request.userId, action));
if (!ok) corrections.push("没有可调整的提案;只有还没开始执行的任务可以调整,且你只能操作自己发起的任务。");
} else if (action.type === "follow_up") {
const ok = await this.scheduler(() => this.followUpLocked(chatKey, request.userId, action.id, action.instruction, request.attachments));
if (!ok) corrections.push("现在没法继续:只有待确认的任务可以继续,且你只能操作自己发起的任务;如果有别的任务正在执行,也要先等它落定。");
} else if (action.type === "finish") {
const ok = await this.scheduler(() => this.finishLocked(chatKey, request.userId, action.id, action.note));
if (!ok) corrections.push("没有可结束的任务;只有待确认的任务可以 finish,且你只能操作自己发起的任务。");
} else if (action.type === "start_next") {
const started = await this.scheduler(() => this.tryStartLocked(chatKey, request.userId));
if (!started) corrections.push(this.startNextCorrection(chatKey, request.userId));
@@ -258,89 +307,99 @@ export class AssistantManager implements ConversationRuntime {
}
private startNextCorrection(chatKey: string, userId: string): string {
const queuedMine = this.proposals.list({ status: "queued" })
.some((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
const queuedMine = this.proposals.list({ status: "queued" }).some(owned);
const kept = queuedMine ? "你的任务已保留在队列里。" : "";
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) {
return `还不能开始:还有一个任务在等发起人确认。${kept}`.trim();
}
if (this.active || this.proposals.list({ status: "working" }).length > 0) {
return `还不能开始:还有一个任务正在执行。${kept}`.trim();
}
if (this.proposals.list({ status: "pending" }).some(owned)) {
return `还不能开始:你有任务还在等你确认,先 finish 或直接继续说要求。${kept}`.trim();
}
if (this.proposals.list({ status: "pending" }).some((proposal) => proposal.pending?.workspaceDirty)) {
return `还不能开始:有任务留下了未整理的工作区,需要先处理 dirty 工作区。${kept}`.trim();
}
return "还不能开始:你没有已确认并排队的任务。";
}
private async confirmLocked(chatKey: string, userId: string, id?: string, answer?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "awaiting_user_confirmation"]);
private async confirmLocked(chatKey: string, userId: string, id?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed"]);
if (!proposal) return false;
if (proposal.status === "proposed") {
await this.proposals.update(proposal.id, { status: "queued", confirmedAt: Date.now() });
await this.tryStartLocked(chatKey, userId);
return true;
}
const pending = proposal.pending;
if (!pending) return false;
if (pending.kind === "success") {
await this.terminateActiveLocked(proposal.id);
await this.proposals.update(proposal.id, { status: "completed", pending: undefined, finishedAt: Date.now() });
return true;
}
if (pending.kind === "failure") {
await this.terminateActiveLocked(proposal.id);
if (answer && answer.trim().toLowerCase() === "retry") {
await this.proposals.update(proposal.id, { status: "working", pending: undefined, startedAt: Date.now() });
this.launchWorker(proposal.id);
} else {
await this.proposals.update(proposal.id, { status: "failed", pending: undefined, finishedAt: Date.now() });
}
return true;
}
await this.proposals.update(proposal.id, { status: "working", pending: undefined });
this.continueWorker(proposal.id, answer || "Confirmed. Please continue.", pending.question);
await this.proposals.update(proposal.id, { status: "queued", confirmedAt: Date.now() });
await this.tryStartLocked(chatKey, userId);
return true;
}
private async adjustProposalLocked(chatKey: string, userId: string, action: { id?: string; title?: string; goal?: string; steps?: string[] }): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, action.id, ["proposed", "queued"]);
if (!proposal) return false;
const patch: { title?: string; goal?: string; steps?: string[] } = {};
if (action.title !== undefined) patch.title = action.title;
if (action.goal !== undefined) patch.goal = action.goal;
if (action.steps !== undefined) patch.steps = action.steps;
if (Object.keys(patch).length === 0) return false;
await this.proposals.update(proposal.id, patch);
return true;
}
private async followUpLocked(chatKey: string, userId: string, id: string | undefined, instruction: string, attachments?: IncomingAttachment[]): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["pending"]);
if (!proposal) return false;
if (this.active || this.proposals.list({ status: "working" }).length > 0) return false;
await this.proposals.update(proposal.id, { status: "working", pending: undefined, startedAt: Date.now() });
this.launchWorker(proposal.id, {
resumeSessionId: proposal.workerNativeSessionId,
firstTurn: workerFollowUpPrompt(instruction, proposal.pending?.question),
freshSessionNote: "The previous worker session could not be resumed; this is a fresh session continuing the same proposal.",
images: attachments
});
return true;
}
private async finishLocked(chatKey: string, userId: string, id?: string, note?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["pending"]);
if (!proposal) return false;
await this.terminateActiveLocked(proposal.id);
await this.proposals.update(proposal.id, {
status: "finished",
finishKind: "done",
finishNote: note,
pending: undefined,
finishedAt: Date.now()
});
return true;
}
private async cancelLocked(chatKey: string, userId: string, id?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "queued"]);
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "queued", "pending"]);
if (!proposal) return false;
await this.proposals.update(proposal.id, { status: "cancelled", finishedAt: Date.now() });
await this.terminateActiveLocked(proposal.id);
await this.proposals.update(proposal.id, {
status: "finished",
finishKind: "cancelled",
pending: undefined,
finishedAt: Date.now()
});
return true;
}
private async stopActiveLocked(chatKey: string, userId: string): Promise<boolean> {
const active = this.active;
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
if (active && activeProposal && activeProposal.ownerChatKey === chatKey && activeProposal.requesterUserId === userId) {
active.settled = true;
this.active = undefined;
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
void active.run?.catch(() => undefined);
await this.proposals.update(activeProposal.id, {
status: "cancelled",
pending: undefined,
workerProcessGroup: undefined,
lastWorkerSummary: "stopped by user",
finishedAt: Date.now()
});
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()
});
}
if (!active || !activeProposal || activeProposal.ownerChatKey !== chatKey || activeProposal.requesterUserId !== userId) return false;
active.settled = true;
this.active = undefined;
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
void active.run?.catch(() => undefined);
const summary = "被用户中止";
await this.proposals.update(activeProposal.id, {
status: "pending",
pending: { summary, workspaceDirty: true, receivedAt: Date.now() },
workerProcessGroup: undefined,
lastWorkerSummary: summary
});
return true;
}
@@ -358,7 +417,9 @@ export class AssistantManager implements ConversationRuntime {
private async tryStartLocked(chatKey: string, userId: string): Promise<boolean> {
if (this.active) return false;
if (this.proposals.list({ status: "working" }).length > 0) return false;
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) return false;
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
if (this.proposals.list({ status: "pending" }).some(owned)) return false;
if (this.proposals.list({ status: "pending" }).some((proposal) => proposal.pending?.workspaceDirty)) return false;
const next = this.proposals.list({ status: "queued" })
.find((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
if (!next) return false;
@@ -378,10 +439,18 @@ export class AssistantManager implements ConversationRuntime {
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
}
private launchWorker(proposalId: string, resumeContext?: string): void {
private launchWorker(proposalId: string, options: WorkerLaunchOptions = {}): void {
const proposal = this.proposals.get(proposalId);
if (!proposal || proposal.status !== "working") return;
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => this.onWorkerCrash(proposalId, crashed, error), {
const active: ActiveWorker = { proposalId, worker: this.spawnProposalWorker(proposalId), settled: false };
this.active = active;
const run = this.startWorkerRun(active, options);
active.run = run;
void run.catch((error) => this.handleWorkerRunFailure(active, error));
}
private spawnProposalWorker(proposalId: string): AcpWorker {
return new AcpWorker(this.bot, this.config, (crashed, error) => this.onWorkerCrash(proposalId, crashed, error), {
kind: "worker",
cwd: this.bot.workspace,
onSpawn: async (group) => {
@@ -389,42 +458,37 @@ export class AssistantManager implements ConversationRuntime {
await this.proposals.update(proposalId, { workerProcessGroup: group });
}
});
const active: ActiveWorker = { proposalId, worker, settled: false };
this.active = active;
const run = this.startWorkerRun(active, resumeContext);
active.run = run;
void run.catch((error) => this.handleWorkerRunFailure(active, error));
}
private continueWorker(proposalId: string, answer: string, question?: string): void {
const active = this.active;
if (active && active.proposalId === proposalId && active.worker.nativeSessionId) {
active.settled = false;
const run = this.runWorkerTurn(active, workerAnswerPrompt(answer, question));
active.run = run;
void run.catch((error) => this.handleWorkerRunFailure(active, error));
return;
private async startWorkerRun(active: ActiveWorker, options: WorkerLaunchOptions): Promise<void> {
if (options.resumeSessionId) {
try {
await active.worker.start(options.resumeSessionId);
} catch (error) {
if (!(error instanceof AcpSessionRestoreError) || this.active !== active || active.settled) throw error;
active.worker = this.spawnProposalWorker(active.proposalId);
await active.worker.start();
options = {
...options,
resumeContext: [options.resumeContext, options.freshSessionNote].filter(Boolean).join("\n"),
firstTurn: options.firstTurn && options.freshSessionNote ? `${options.firstTurn}\n\n${options.freshSessionNote}` : options.firstTurn
};
}
} else {
await active.worker.start();
}
const proposal = this.proposals.get(proposalId);
const context = proposal
? `The previous worker was lost. Its last summary: ${proposal.lastWorkerSummary || "unknown"}. Its question: ${question || "unknown"}. The user answered: ${answer}`
: answer;
this.launchWorker(proposalId, context);
}
private async startWorkerRun(active: ActiveWorker, resumeContext?: string): Promise<void> {
const nativeSessionId = await active.worker.start();
if (this.active !== active || active.settled) throw new Error("Worker was superseded before its session started");
await this.proposals.update(active.proposalId, { workerNativeSessionId: nativeSessionId });
await this.proposals.update(active.proposalId, { workerNativeSessionId: active.worker.nativeSessionId });
await active.worker.prompt(this.bot.workerBootstrap, "initializing");
if (this.active !== active || active.settled) throw new Error("Worker was superseded during bootstrap");
const proposal = this.proposals.get(active.proposalId);
if (!proposal) throw new Error("Worker proposal no longer exists");
await this.runWorkerTurn(active, workerTaskPrompt(proposal, resumeContext));
const firstTurn = options.firstTurn || workerTaskPrompt(proposal, options.resumeContext);
await this.runWorkerTurn(active, firstTurn, options.images);
}
private async runWorkerTurn(active: ActiveWorker, promptText: string): Promise<void> {
const reply = await active.worker.prompt(withWorkerResultProtocol(promptText));
private async runWorkerTurn(active: ActiveWorker, promptText: string, images?: IncomingAttachment[]): Promise<void> {
const reply = await active.worker.prompt(withWorkerResultProtocol(this.workerPromptContent(active.worker, promptText, images)));
if (this.active !== active || active.settled) throw new Error("Worker was superseded during its turn");
let result = parseWorkerResult(reply);
if (!result) {
@@ -432,10 +496,19 @@ export class AssistantManager implements ConversationRuntime {
if (this.active !== active || active.settled) throw new Error("Worker was superseded during result repair");
result = parseWorkerResult(repaired);
}
if (!result) throw new Error("Worker did not return a valid GORI_WORKER_RESULT_V1 envelope after one repair attempt");
if (!result) throw new Error("Worker did not return a valid GORI_WORKER_RESULT_V2 envelope after one repair attempt");
await this.settleWorkerResult(active, result);
}
private workerPromptContent(worker: AcpWorker, text: string, images?: IncomingAttachment[]): string | AcpPromptContent[] {
if (!images || images.length === 0) return text;
if (!worker.supportsImages) return `${text}\n\n[Attachments]\n${images.map(() => "[图片,当前 agent 不支持图片输入]").join("\n")}`;
return [
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
{ type: "text" as const, text }
];
}
private async settleWorkerResult(active: ActiveWorker, result: ParsedWorkerResult): Promise<void> {
if (this.active !== active || active.settled || this.shuttingDown) return;
const transitioned = await this.scheduler(async () => {
@@ -443,21 +516,20 @@ export class AssistantManager implements ConversationRuntime {
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.status !== "working") { active.settled = true; return false; }
active.settled = true;
const pending: ProposalPending = result.status === "SUCCESS"
? { kind: "success", summary: result.summary }
: result.status === "FAILED"
? { kind: "failure", summary: result.summary }
: { kind: "step", summary: result.summary, question: result.question, nextStep: result.nextStep };
this.active = undefined;
const pending: ProposalPending = {
summary: result.summary,
question: result.question,
workspaceDirty: result.workspaceDirty,
receivedAt: Date.now()
};
await this.proposals.update(active.proposalId, {
status: "awaiting_user_confirmation",
status: "pending",
pending,
lastWorkerSummary: result.summary
});
if (result.status !== "NEEDS_CONFIRMATION") {
this.active = undefined;
await active.worker.terminate().catch(() => undefined);
await this.proposals.update(active.proposalId, { workerProcessGroup: undefined });
}
await active.worker.terminate().catch(() => undefined);
await this.proposals.update(active.proposalId, { workerProcessGroup: undefined });
return true;
});
if (transitioned) await this.notifyOwner(active.proposalId);
@@ -475,8 +547,8 @@ export class AssistantManager implements ConversationRuntime {
this.active = undefined;
const summary = `worker_error: ${error instanceof Error ? error.message : String(error)}`;
await this.proposals.update(active.proposalId, {
status: "awaiting_user_confirmation",
pending: { kind: "failure", summary },
status: "pending",
pending: { summary, workspaceDirty: true, receivedAt: Date.now() },
lastWorkerSummary: summary,
workerProcessGroup: undefined
});
@@ -646,6 +718,9 @@ export class AssistantManager implements ConversationRuntime {
"[Proposal states]",
...this.proposalSummaries(chatKey, request.userId),
"",
"[Pending proposals]",
...this.pendingCards(chatKey, request.userId),
"",
"[Scheduler state]",
this.schedulerStateSummary(chatKey, request.userId),
"",
@@ -654,15 +729,26 @@ export class AssistantManager implements ConversationRuntime {
].join("\n");
}
private pendingCards(chatKey: string, userId: string): string[] {
const pending = this.proposals.list({ status: "pending" })
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
if (pending.length === 0) return ["none"];
return pending.map((proposal) => {
const card = proposal.pending!;
return `- id=${proposal.id} title=${JSON.stringify(proposal.title)} summary=${JSON.stringify(card.summary)}${card.question ? ` question=${JSON.stringify(card.question)}` : ""}${card.workspaceDirty ? " workspaceDirty=true" : ""}`;
});
}
private schedulerStateSummary(chatKey: string, userId: string): string {
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
const awaitingOther = this.proposals.list({ status: "awaiting_user_confirmation" }).some((proposal) => !owned(proposal));
if (awaitingOther) return "blocked: another proposal is awaiting owner confirmation";
const active = this.active;
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
const workingOther = this.proposals.list({ status: "working" }).some((proposal) => !owned(proposal))
|| Boolean(activeProposal && !owned(activeProposal));
if (workingOther) return "busy: another proposal is working";
const dirtyOther = this.proposals.list({ status: "pending" })
.some((proposal) => !owned(proposal) && proposal.pending?.workspaceDirty);
if (dirtyOther) return "blocked: another proposal left a dirty workspace that needs attention";
return "idle";
}
@@ -672,7 +758,7 @@ export class AssistantManager implements ConversationRuntime {
if (proposals.length === 0) return ["none"];
return proposals.map((proposal) => {
const pending = proposal.pending
? ` pending=${proposal.pending.kind} summary=${JSON.stringify(proposal.pending.summary)}${proposal.pending.question ? ` question=${JSON.stringify(proposal.pending.question)}` : ""}`
? ` summary=${JSON.stringify(proposal.pending.summary)}${proposal.pending.question ? ` question=${JSON.stringify(proposal.pending.question)}` : ""}`
: "";
return `- id=${proposal.id} status=${proposal.status} title=${JSON.stringify(proposal.title)}${pending}`;
});
@@ -733,8 +819,30 @@ function parseAssistantAction(value: unknown): AssistantAction | undefined {
return { type: "create_proposal", title: value.title, goal: value.goal, steps: value.steps as string[] };
}
if (value.type === "confirm") {
if ((value.id !== undefined && typeof value.id !== "string") || (value.answer !== undefined && typeof value.answer !== "string")) return undefined;
return { type: "confirm", id: value.id as string | undefined, answer: value.answer as string | undefined };
if (value.id !== undefined && typeof value.id !== "string") return undefined;
return { type: "confirm", id: value.id as string | undefined };
}
if (value.type === "adjust_proposal") {
if ((value.id !== undefined && typeof value.id !== "string")
|| (value.title !== undefined && (typeof value.title !== "string" || value.title.length === 0))
|| (value.goal !== undefined && (typeof value.goal !== "string" || value.goal.length === 0))
|| (value.steps !== undefined && (!Array.isArray(value.steps) || value.steps.some((step) => typeof step !== "string" || step.length === 0)))) return undefined;
return {
type: "adjust_proposal",
id: value.id as string | undefined,
title: value.title as string | undefined,
goal: value.goal as string | undefined,
steps: value.steps as string[] | undefined
};
}
if (value.type === "follow_up") {
if (typeof value.instruction !== "string" || value.instruction.length === 0
|| (value.id !== undefined && typeof value.id !== "string")) return undefined;
return { type: "follow_up", id: value.id as string | undefined, instruction: value.instruction };
}
if (value.type === "finish") {
if ((value.id !== undefined && typeof value.id !== "string") || (value.note !== undefined && typeof value.note !== "string")) return undefined;
return { type: "finish", id: value.id as string | undefined, note: value.note as string | undefined };
}
if (value.type === "start_next") return { type: "start_next" };
if (value.type === "cancel") {
@@ -751,29 +859,29 @@ export function parseWorkerResult(text: string): ParsedWorkerResult | undefined
let value: unknown;
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
if (!isRecord(value) || typeof value.summary !== "string") return undefined;
if (value.status !== "SUCCESS" && value.status !== "NEEDS_CONFIRMATION" && value.status !== "FAILED") return undefined;
if (value.status !== "PENDING") return undefined;
if ((value.question !== undefined && typeof value.question !== "string")
|| (value.nextStep !== undefined && typeof value.nextStep !== "string")
|| (value.dirty !== undefined && typeof value.dirty !== "boolean")) return undefined;
|| (value.workspaceDirty !== undefined && typeof value.workspaceDirty !== "boolean")) return undefined;
return {
status: value.status,
status: "PENDING",
summary: value.summary,
question: value.question as string | undefined,
nextStep: value.nextStep as string | undefined,
dirty: value.dirty as boolean | undefined
workspaceDirty: value.workspaceDirty as boolean | undefined
};
}
function withWorkerResultProtocol(text: string): string {
return `${text}\n\nWhen this turn is finished, end your response with exactly one hidden worker result envelope. Use <GORI_WORKER_RESULT_V1>{"status":"SUCCESS","summary":"..."}</GORI_WORKER_RESULT_V1> only when the proposal is fully complete, status "FAILED" with a summary when it cannot be completed, or status "NEEDS_CONFIRMATION" with a "question" when user confirmation is required before continuing. Optional fields: "nextStep", "dirty". Do not emit any other status value or any text after the envelope.`;
function withWorkerResultProtocol(input: string | AcpPromptContent[]): string | AcpPromptContent[] {
const instruction = "When this turn is finished, end your response with exactly one hidden worker result envelope: <GORI_WORKER_RESULT_V2>{\"status\":\"PENDING\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V2>. The summary is a short user-readable description of what you did or what is blocking you. Add a \"question\" when you need the user's decision before continuing, and set \"workspaceDirty\": true when you left the workspace modified or are unsure about its state. PENDING is the only status; do not emit any other status value or any text after the envelope.";
if (typeof input === "string") return `${input}\n\n${instruction}`;
return [...input, { type: "text" as const, text: `\n\n${instruction}` }];
}
function workerRepairPrompt(): string {
return "Your previous response did not end with a valid GORI_WORKER_RESULT_V1 envelope. Return exactly one valid envelope with status SUCCESS, NEEDS_CONFIRMATION, or FAILED and a summary. Do not perform more work.";
return "Your previous response did not end with a valid GORI_WORKER_RESULT_V2 envelope. Return exactly one valid envelope with status PENDING and a summary. Do not perform more work.";
}
function assistantRepairPrompt(): string {
return "Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V1 envelope. Reply again with the user-facing text in the JSON \"reply\" field and an \"actions\" array (empty if no state change is needed).";
return "Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V2 envelope. Reply again with the user-facing text in the JSON \"reply\" field and an \"actions\" array (empty if no state change is needed).";
}
function workerTaskPrompt(proposal: Proposal, resumeContext?: string): string {
@@ -785,15 +893,15 @@ function workerTaskPrompt(proposal: Proposal, resumeContext?: string): string {
...proposal.steps.map((step, index) => `${index + 1}. ${step}`),
...(resumeContext ? ["", "Additional context:", resumeContext] : []),
"",
"If you encounter a dirty or unexpected target, or anything that requires the user's decision, stop and report NEEDS_CONFIRMATION with a clear question instead of forcing the change."
"If you encounter a dirty or unexpected target, or anything that requires the user's decision, stop and report PENDING with a clear question instead of forcing the change."
].join("\n");
}
function workerAnswerPrompt(answer: string, question?: string): string {
function workerFollowUpPrompt(instruction: string, question?: string): string {
return [
"The user answered your question about the current proposal.",
`Your question was: ${question || "unknown"}`,
`User answer: ${answer}`,
"The user sent a follow-up instruction for the current proposal.",
question ? `Your last question was: ${question}` : "You had no open question.",
`User instruction: ${instruction}`,
"Continue executing the same proposal accordingly."
].join("\n");
}
@@ -802,18 +910,16 @@ function assistantEventPrompt(proposal: Proposal): string {
const pending = proposal.pending!;
return [
"[Internal event from gori-agent. This is not a user message.]",
`The worker for proposal "${proposal.title}" (id ${proposal.id}) reported ${pending.kind === "step" ? "NEEDS_CONFIRMATION" : pending.kind === "success" ? "SUCCESS" : "FAILED"}.`,
`The worker for proposal "${proposal.title}" (id ${proposal.id}) reported its result and the proposal is now waiting for the user's decision.`,
`Summary: ${pending.summary}`,
pending.question ? `Question for the user: ${pending.question}` : "Question for the user: none",
pending.nextStep ? `Suggested next step: ${pending.nextStep}` : "Suggested next step: none",
"Tell the user, in your own words, what happened and what they can do next (confirm, answer the question, retry, or stop). End with the usual GORI_ASSISTANT_ACTION_V1 envelope; its actions array MUST be empty because actions are ignored for internal events."
"Tell the user, in your own words, what happened and what they can do next (finish to close the task, follow up with new instructions to continue it, or stop). End with the usual GORI_ASSISTANT_ACTION_V2 envelope; its actions array MUST be empty because actions are ignored for internal events."
].join("\n");
}
function fallbackEventText(proposal: Proposal): string {
const pending = proposal.pending!;
const state = pending.kind === "step" ? "等待你回答一个问题" : pending.kind === "success" ? "执行完成,待你确认" : "执行失败,待你确认或重试";
return `任务「${proposal.title}」${state}。摘要:${pending.summary}${pending.question ? ` 问题:${pending.question}` : ""}`;
return `任务「${proposal.title}」等你确认。摘要:${pending.summary}${pending.question ? ` 问题:${pending.question}` : ""} 说 finish 结束,或直接说要求继续改。`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
+11 -2
View File
@@ -6,6 +6,10 @@ import type { PermissionPolicy } from "../config.js";
export type SafeActivityCategory = "read" | "write" | "execute" | "search" | "delegate" | "other";
export type AcpPromptContent =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };
export interface AcpClientOptions {
initializeTimeoutMs: number;
policy: PermissionPolicy;
@@ -88,9 +92,10 @@ export class AcpClient {
this.chunks = [];
}
async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> {
async prompt(input: string | AcpPromptContent[], cancellationSignal?: AbortSignal): Promise<string> {
if (!this.activeSessionId) throw new Error("ACP session is not active");
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
const content: AcpPromptContent[] = typeof input === "string" ? [{ type: "text", text: input }] : input;
this.chunks = [];
this.collecting = true;
const violation = new Promise<never>((_resolve, reject) => { this.violationReject = reject; });
@@ -98,7 +103,7 @@ export class AcpClient {
await Promise.race([
this.connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: this.activeSessionId,
prompt: [{ type: "text", text }]
prompt: content
}, cancellationSignal ? { cancellationSignal } : undefined),
violation
]);
@@ -110,6 +115,10 @@ export class AcpClient {
}
}
supportsImageInput(): boolean {
return this.capabilities.promptCapabilities?.image === true;
}
async settleIsolation(): Promise<void> {
if (!this.options.forbidToolActivity) return;
await new Promise((resolve) => setTimeout(resolve, 50));
+3
View File
@@ -1,5 +1,6 @@
import type { PermissionPolicy } from "../config.js";
import type { Proposal } from "../core/proposal-store.js";
import type { IncomingAttachment } from "../core/types.js";
export interface AcpBackendSpec {
id: string;
@@ -14,6 +15,7 @@ export interface ConversationRequest {
userId: string;
text: string;
messageId?: string;
attachments?: IncomingAttachment[];
}
export interface ConversationResponse {
@@ -41,6 +43,7 @@ export interface ConversationRuntime {
shutdown(): Promise<void>;
listProposals?(platform: string, chatId: string, userId: string): Proposal[];
confirm?(platform: string, chatId: string, userId: string): Promise<boolean>;
finish?(platform: string, chatId: string, userId: string): Promise<boolean>;
stop?(platform: string, chatId: string, userId: string): Promise<boolean>;
setEventSink?(sink: RuntimeEventSink): void;
}
+5 -3
View File
@@ -4,7 +4,7 @@ import fs from "node:fs";
import type { AcpConfig, PermissionPolicy } from "../config.js";
import type { WorkerProcessGroup } from "../core/proposal-store.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import { AcpClient, type SafeActivityCategory } from "./client.js";
import { AcpClient, type AcpPromptContent, type SafeActivityCategory } from "./client.js";
export class AcpSessionRestoreError extends Error {}
@@ -35,6 +35,7 @@ export class AcpWorker {
nativeSessionId?: string;
processGroup?: WorkerProcessGroup;
isolationViolated = false;
supportsImages = false;
lastUsedAt = Date.now();
turnStartedAt?: number;
lastActivityAt?: number;
@@ -94,6 +95,7 @@ export class AcpWorker {
this.processGroup = { pgid: this.child.pid, token: processToken };
await this.options.onSpawn?.(this.processGroup);
const initialized = await this.client.initialize();
this.supportsImages = this.client.supportsImageInput();
if (this.kind === "assistant" && !this.options.allowUnverifiedAssistantAgent && initialized.agentInfo?.name !== "Kimi Code CLI") {
throw new Error("Assistant sessions require Kimi Code ACP with execution-layer no-tool profiles");
}
@@ -111,7 +113,7 @@ export class AcpWorker {
}
}
async prompt(text: string, phase: Exclude<AcpWorkerPhase, "idle"> = "processing"): Promise<string> {
async prompt(input: string | AcpPromptContent[], phase: Exclude<AcpWorkerPhase, "idle"> = "processing"): Promise<string> {
if (!this.client || !this.nativeSessionId || this.exited) throw new Error("ACP worker is not available");
if (this.inFlight) throw new Error("ACP worker already has an in-flight turn");
const now = Date.now();
@@ -131,7 +133,7 @@ export class AcpWorker {
}, this.config.promptTimeoutMs);
});
try {
const result = await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]);
const result = await Promise.race([this.client.prompt(input, this.abort.signal), timeoutPromise]);
if (this.kind === "assistant") await this.client.settleIsolation();
return result;
} finally {
+2 -1
View File
@@ -1,4 +1,4 @@
export type CommandKind = "help" | "status" | "list" | "confirm" | "stop" | "cancel";
export type CommandKind = "help" | "status" | "list" | "confirm" | "finish" | "stop" | "cancel";
export interface ParsedCommand {
kind: CommandKind;
@@ -14,6 +14,7 @@ export class CommandRouter {
case "/status": return { kind: "status" };
case "/list": return { kind: "list" };
case "/confirm": return { kind: "confirm" };
case "/finish": return { kind: "finish" };
case "/stop": return { kind: "stop" };
case "/cancel": return { kind: "cancel" };
default: return undefined;
+53 -8
View File
@@ -3,6 +3,7 @@ import type { GatewayPolicy } from "../config.js";
import type { PlatformAdapter } from "./adapter.js";
import { CommandRouter, type ParsedCommand } from "./command-router.js";
import { chatKeyFor } from "./durable-session-store.js";
import type { Proposal } from "./proposal-store.js";
import type { IncomingMessage, MessageTarget } from "./types.js";
export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean; error?: string }
@@ -114,7 +115,8 @@ export class Gateway {
chatId: message.chatId,
userId: message.userId,
text: message.text,
messageId: message.messageId
messageId: message.messageId,
attachments: message.attachments
})).text;
await this.replyStream(chatKey, message, adapter).enqueue(reply);
return { ok: true, reply };
@@ -237,7 +239,8 @@ export class Gateway {
chatId: message.chatId,
userId: message.userId,
text: message.text,
messageId: message.messageId
messageId: message.messageId,
attachments: message.attachments
})).text;
return { ok: true, reply };
} catch (error) {
@@ -256,17 +259,19 @@ export class Gateway {
}
case "list": {
if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
const proposals = this.runtime.listProposals(message.platform, message.chatId, message.userId);
if (proposals.length === 0) return "当前没有提案。";
return proposals.map((proposal) => `${proposal.id} [${proposal.status}] ${proposal.title}`).join("\n");
return renderProposalPanel(this.runtime.listProposals(message.platform, message.chatId, message.userId));
}
case "confirm": {
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
return await this.runtime.confirm(message.platform, message.chatId, message.userId) ? "已确认,继续执行。" : "当前没有待确认的提案。";
return await this.runtime.confirm(message.platform, message.chatId, message.userId) ? "已确认,加入队列。" : "当前没有待确认的提案。";
}
case "finish": {
if (!this.runtime.finish) return "当前运行时不支持结束操作。";
return await this.runtime.finish(message.platform, message.chatId, message.userId) ? "已结束该任务。" : "当前没有待结束的任务。";
}
case "stop": {
if (!this.runtime.stop) return "当前运行时不支持停止操作。";
return await this.runtime.stop(message.platform, message.chatId, message.userId) ? "已停止当前任务。" : "当前没有正在执行的任务。";
return await this.runtime.stop(message.platform, message.chatId, message.userId) ? "已停止当前任务,任务转为待确认。" : "当前没有正在执行的任务。";
}
case "cancel":
return await this.runtime.cancel(message.platform, message.chatId, message.userId) ? "已取消最近的提案。" : "没有可取消的提案。";
@@ -282,6 +287,45 @@ export class Gateway {
}
function renderProposalPanel(proposals: Proposal[]): string {
if (proposals.length === 0) return "当前没有提案。";
const pending = proposals.filter((proposal) => proposal.status === "pending");
const working = proposals.filter((proposal) => proposal.status === "working");
const queued = proposals.filter((proposal) => proposal.status === "queued");
const proposed = proposals.filter((proposal) => proposal.status === "proposed");
const finished = proposals.filter((proposal) => proposal.status === "finished")
.sort((left, right) => (right.finishedAt ?? right.updatedAt) - (left.finishedAt ?? left.updatedAt))
.slice(0, 3);
const sections: string[] = [];
if (pending.length > 0) {
sections.push("待确认(等你处理):");
for (const proposal of pending) {
const card = proposal.pending;
sections.push(`- ${proposal.id}「${proposal.title}」${card ? `:${card.summary}${card.question ? `(问你:${card.question})` : ""}` : ""}(说 /finish 结束,或直接说要求继续改)`);
}
}
if (working.length > 0) {
sections.push("进行中:");
for (const proposal of working) sections.push(`- ${proposal.id}「${proposal.title}」正在执行(/stop 可中止)`);
}
if (queued.length > 0) {
sections.push("排队中:");
for (const proposal of queued) sections.push(`- ${proposal.id}「${proposal.title}」`);
}
if (proposed.length > 0) {
sections.push("未确认(proposed):");
for (const proposal of proposed) sections.push(`- ${proposal.id}「${proposal.title}」(/confirm 确认后排队)`);
}
if (finished.length > 0) {
sections.push("最近结束:");
for (const proposal of finished) {
const state = proposal.finishKind === "cancelled" ? "已取消" : "已完成";
sections.push(`- ${proposal.id}「${proposal.title}」${state}${proposal.finishNote ? `(${proposal.finishNote})` : ""}`);
}
}
return sections.join("\n");
}
function safeEventError(error: unknown): string {
const message = error instanceof Error ? error.message : "";
const http = /HTTP (\d{3})/.exec(message);
@@ -296,7 +340,8 @@ const HELP_TEXT = [
"兜底命令:",
"/list 列出当前提案",
"/confirm 确认最近待确认的提案",
"/finish 结束最近待确认的任务",
"/stop 停止正在执行的任务",
"/cancel 取消最近未开始的提案",
"/cancel 取消最近未开始或待确认的提案",
"/status 查看运行状态"
].join("\n");
+96 -16
View File
@@ -16,18 +16,18 @@ export type ProposalStatus =
| "proposed"
| "queued"
| "working"
| "awaiting_user_confirmation"
| "completed"
| "failed"
| "cancelled";
| "pending"
| "finished";
export interface ProposalPending {
kind: "step" | "success" | "failure";
summary: string;
question?: string;
nextStep?: string;
workspaceDirty?: boolean;
receivedAt: number;
}
export type ProposalFinishKind = "done" | "cancelled";
export interface Proposal {
id: string;
title: string;
@@ -39,6 +39,8 @@ export interface Proposal {
workerNativeSessionId?: string;
workerProcessGroup?: WorkerProcessGroup;
pending?: ProposalPending;
finishKind?: ProposalFinishKind;
finishNote?: string;
lastWorkerSummary?: string;
createdAt: number;
confirmedAt?: number;
@@ -63,6 +65,8 @@ export interface ProposalPatch {
workerNativeSessionId?: string;
workerProcessGroup?: WorkerProcessGroup;
pending?: ProposalPending;
finishKind?: ProposalFinishKind;
finishNote?: string;
lastWorkerSummary?: string;
confirmedAt?: number;
startedAt?: number;
@@ -70,7 +74,7 @@ export interface ProposalPatch {
}
interface StoreData {
version: 1;
version: 2;
botId: string;
platform: string;
proposals: Record<string, Proposal>;
@@ -116,11 +120,20 @@ export class ProposalStore {
}
try {
const raw = await fs.promises.readFile(this.file, "utf8");
const parsed = parseStoreData(JSON.parse(raw) as unknown);
if (parsed.botId !== this.identity.botId || parsed.platform !== this.identity.platform) {
const value = JSON.parse(raw) as unknown;
if (!isRecord(value) || typeof value.botId !== "string" || typeof value.platform !== "string") {
throw new Error("unsupported proposal state; expected a versioned store file");
}
if (value.botId !== this.identity.botId || value.platform !== this.identity.platform) {
throw new Error(`proposal state identity mismatch: expected bot '${this.identity.botId}' platform '${this.identity.platform}'`);
}
this.data = parsed;
if (value.version === 1) {
await this.backupFile(raw);
this.data = migrateV1(value);
await this.persist();
} else {
this.data = parseStoreData(value);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
await this.releaseLock();
@@ -170,6 +183,9 @@ export class ProposalStore {
const proposal = this.data.proposals[id];
if (!proposal) throw new Error(`unknown proposal '${id}'`);
const candidate: Proposal = { ...structuredClone(proposal), ...structuredClone(patch), id, updatedAt: Date.now() };
for (const key of Object.keys(candidate) as (keyof Proposal)[]) {
if (candidate[key] === undefined) delete candidate[key];
}
validateProposal(candidate, id);
this.data.proposals[id] = candidate;
await this.persist();
@@ -196,6 +212,19 @@ export class ProposalStore {
return operation;
}
private async backupFile(raw: string): Promise<void> {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backup = `${this.file}.v1-${stamp}.bak`;
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(backup, "wx", 0o600);
await handle.writeFile(raw, "utf8");
await handle.sync();
} finally {
await handle?.close().catch(() => undefined);
}
}
private async releaseLock(): Promise<void> {
if (!this.lockFd) return;
await this.lockFd.close();
@@ -207,14 +236,15 @@ export class ProposalStore {
}
function emptyData(identity: StoreIdentity): StoreData {
return { version: 1, botId: identity.botId, platform: identity.platform, proposals: {} };
return { version: 2, botId: identity.botId, platform: identity.platform, proposals: {} };
}
const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "awaiting_user_confirmation", "completed", "failed", "cancelled"];
const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "pending", "finished"];
const FINISH_KINDS: readonly ProposalFinishKind[] = ["done", "cancelled"];
function parseStoreData(value: unknown): StoreData {
if (!isRecord(value) || value.version !== 1 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) {
throw new Error("unsupported proposal state version; expected version 1");
if (!isRecord(value) || value.version !== 2 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) {
throw new Error("unsupported proposal state version; expected version 2");
}
for (const [id, proposal] of Object.entries(value.proposals)) {
if (!isRecord(proposal) || proposal.id !== id) throw new Error(`invalid proposal '${id}'`);
@@ -223,6 +253,54 @@ function parseStoreData(value: unknown): StoreData {
return value as unknown as StoreData;
}
interface V1Pending {
kind?: string;
summary?: string;
question?: string;
}
// v1 → v2 migration: pending becomes a single "waiting for the user" state with no success/failure
// distinction; terminal v1 statuses collapse into finished with a finishKind.
function migrateV1(value: Record<string, unknown>): StoreData {
if (!isRecord(value.proposals)) throw new Error("unsupported proposal state version; expected version 2");
const proposals: Record<string, Proposal> = {};
for (const [id, raw] of Object.entries(value.proposals)) {
if (!isRecord(raw) || raw.id !== id) throw new Error(`invalid proposal '${id}'`);
const source = structuredClone(raw) as unknown as Omit<Proposal, "status" | "pending"> & { status: string; pending?: V1Pending };
const pending = isRecord(source.pending) ? source.pending as V1Pending : undefined;
const migrated: Proposal = { ...source, pending: undefined, finishKind: undefined, finishNote: undefined } as Proposal;
if (source.status === "awaiting_user_confirmation") {
migrated.status = "pending";
migrated.pending = {
summary: typeof pending?.summary === "string" ? pending.summary : (source.lastWorkerSummary || "worker reported a result before migration"),
receivedAt: typeof source.updatedAt === "number" ? source.updatedAt : Date.now()
};
if (pending?.kind === "failure") migrated.pending.workspaceDirty = true;
if (pending?.kind === "step" && typeof pending.question === "string") migrated.pending.question = pending.question;
} else if (source.status === "completed") {
migrated.status = "finished";
migrated.finishKind = "done";
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
} else if (source.status === "failed") {
migrated.status = "finished";
migrated.finishKind = "done";
migrated.finishNote = pending?.summary || source.lastWorkerSummary || "failed before migration";
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
} else if (source.status === "cancelled") {
migrated.status = "finished";
migrated.finishKind = "cancelled";
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
} else if (source.status === "proposed" || source.status === "queued" || source.status === "working") {
migrated.status = source.status;
} else {
throw new Error(`invalid proposal '${id}': status`);
}
validateProposal(migrated, id);
proposals[id] = migrated;
}
return { version: 2, botId: value.botId as string, platform: value.platform as string, proposals };
}
function validateProposal(proposal: Proposal, id: string): void {
const invalid = (reason: string): never => { throw new Error(`invalid proposal '${id}': ${reason}`); };
if (typeof proposal.title !== "string" || proposal.title.length === 0) invalid("title");
@@ -241,13 +319,15 @@ function validateProposal(proposal: Proposal, id: string): void {
if (proposal.pending !== undefined) {
const pending = proposal.pending;
if (!isRecord(pending)
|| !["step", "success", "failure"].includes(String(pending.kind))
|| typeof pending.summary !== "string"
|| (pending.question !== undefined && typeof pending.question !== "string")
|| (pending.nextStep !== undefined && typeof pending.nextStep !== "string")) {
|| (pending.workspaceDirty !== undefined && typeof pending.workspaceDirty !== "boolean")
|| typeof pending.receivedAt !== "number") {
invalid("pending");
}
}
if (proposal.finishKind !== undefined && !FINISH_KINDS.includes(proposal.finishKind)) invalid("finishKind");
if (proposal.finishNote !== undefined && typeof proposal.finishNote !== "string") invalid("finishNote");
if (proposal.lastWorkerSummary !== undefined && typeof proposal.lastWorkerSummary !== "string") invalid("lastWorkerSummary");
if (typeof proposal.createdAt !== "number") invalid("createdAt");
if (typeof proposal.updatedAt !== "number") invalid("updatedAt");
+7
View File
@@ -10,6 +10,12 @@ export interface MessageTarget {
raw?: unknown;
}
export interface IncomingAttachment {
mimeType: string;
data: string; // base64
filename?: string;
}
export interface IncomingMessage {
platform: PlatformName;
chatId: string;
@@ -18,6 +24,7 @@ export interface IncomingMessage {
messageId?: string;
isGroup?: boolean;
mentionsBot?: boolean;
attachments?: IncomingAttachment[];
raw?: unknown;
}
+61 -8
View File
@@ -3,7 +3,7 @@ import { jsonResponse } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js";
import type { Gateway } from "../../core/gateway.js";
import { stripBotMentions } from "../../core/text.js";
import type { IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
import type { IncomingAttachment, IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
import { signQqValidation, verifyQqWebhookSignature } from "./crypto.js";
import type { QqAccessTokenResponse, QqSendMessageResponse, QqWebhookEventData, QqWebhookPayload } from "./types.js";
@@ -12,6 +12,11 @@ const MESSAGE_EVENTS = new Set([
"GROUP_AT_MESSAGE_CREATE",
"C2C_MESSAGE_CREATE"
]);
// Inbound attachment limits: only images are downloaded (base64 passthrough to the agent);
// everything else degrades to a text link.
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_IMAGES_PER_MESSAGE = 3;
const ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 10_000;
export class QqAdapter implements PlatformAdapter {
readonly name = "qq";
@@ -38,17 +43,17 @@ export class QqAdapter implements PlatformAdapter {
return jsonResponse(QQ_CALLBACK_ACK);
}
if (!this.handleDispatch(payload)) return jsonResponse(QQ_CALLBACK_ACK);
await this.handleDispatch(payload);
return jsonResponse(QQ_CALLBACK_ACK);
}
handleDispatch(payload: QqWebhookPayload): boolean {
const message = this.normalizeMessage(payload);
async handleDispatch(payload: QqWebhookPayload): Promise<boolean> {
const message = await this.normalizeMessage(payload);
if (!message) {
console.log(`QQ recv ${payload.t} ignored (empty text or missing ids)`);
return false;
}
console.log(`QQ recv ${payload.t} accepted length=${message.text.length}`);
console.log(`QQ recv ${payload.t} accepted length=${message.text.length} images=${message.attachments?.length || 0}`);
void this.gateway.receive(message, this).catch((error) => {
console.error("QQ gateway error", error);
});
@@ -98,13 +103,37 @@ export class QqAdapter implements PlatformAdapter {
});
}
private normalizeMessage(payload: QqWebhookPayload): IncomingMessage | undefined {
private async normalizeMessage(payload: QqWebhookPayload): Promise<IncomingMessage | undefined> {
const data = payload.d;
if (!data) return undefined;
const rawText = data.content || data.text || "";
const text = stripBotMentions(stripConfiguredBotNames(rawText, this.config.botNames));
if (!text) return undefined;
const attachments = Array.isArray(data.attachments) ? data.attachments : [];
const linkLines: string[] = [];
const imageSources = attachments.filter((attachment) => {
const type = typeof attachment?.content_type === "string" ? attachment.content_type : "";
if (type.startsWith("image/")) return typeof attachment.url === "string" && attachment.url.length > 0;
if (typeof attachment?.url === "string" && attachment.url) {
linkLines.push(type.startsWith("video/") ? `[视频] ${attachment.url}` : `[文件] ${attachment.url}`);
}
return false;
});
const images: IncomingAttachment[] = [];
const downloadable = imageSources.filter((source) => !(typeof source.size === "number" && source.size > MAX_IMAGE_BYTES));
let skippedImages = imageSources.length - downloadable.length + Math.max(0, downloadable.length - MAX_IMAGES_PER_MESSAGE);
for (const source of downloadable.slice(0, MAX_IMAGES_PER_MESSAGE)) {
const image = await downloadImage(source);
if (image) images.push(image);
else skippedImages++;
}
if (attachments.length > 0) {
console.log(`QQ attachments: total=${attachments.length} images=${imageSources.length} downloaded=${images.length} skipped=${skippedImages} links=${linkLines.length}`);
}
const parts = [text, ...linkLines].filter((part) => part.length > 0);
if (parts.length === 0 && images.length > 0) parts.push(images.length > 1 ? `(发来 ${images.length} 张图片)` : "(发来一张图片)");
if (parts.length === 0) return undefined;
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);
@@ -115,10 +144,11 @@ export class QqAdapter implements PlatformAdapter {
platform: this.name,
chatId,
userId,
text,
text: parts.join("\n"),
messageId: data.id || data.msg_id || data.message_id || payload.id,
isGroup,
mentionsBot: isGroup || this.config.botNames.some((name) => rawText.includes(`@${name}`) || rawText.includes(name)),
attachments: images.length > 0 ? images : undefined,
raw: data
};
}
@@ -148,6 +178,29 @@ export class QqAdapter implements PlatformAdapter {
}
}
async function downloadImage(attachment: { content_type?: string; filename?: string; url?: string }): Promise<IncomingAttachment | undefined> {
const rawUrl = typeof attachment.url === "string" ? attachment.url : "";
const url = rawUrl.startsWith("//") ? `https:${rawUrl}` : rawUrl;
if (!/^https?:\/\//.test(url)) return undefined;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ATTACHMENT_DOWNLOAD_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) return undefined;
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES) return undefined;
return {
mimeType: attachment.content_type || "image/*",
data: buffer.toString("base64"),
filename: typeof attachment.filename === "string" ? attachment.filename : undefined
};
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
function qqErrorDetail(data: QqSendMessageResponse | undefined): string {
if (!data) return "";
const parts = [data.code ? `code=${data.code}` : "", data.message || ""].filter(Boolean);
+1 -1
View File
@@ -91,7 +91,7 @@ export class QqGatewayClient {
return;
}
if (payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "C2C_MESSAGE_CREATE") {
this.adapter.handleDispatch(payload);
void this.adapter.handleDispatch(payload).catch(() => undefined);
}
return;
}
+7
View File
@@ -47,6 +47,13 @@ export interface QqWebhookEventData {
guild_id?: string;
user_openid?: string;
member_openid?: string;
attachments?: {
content_type?: string;
filename?: string;
id?: string;
size?: number;
url?: string;
}[];
author?: {
id?: string;
user_openid?: string;
+10 -9
View File
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
import type { AppConfig, BotConfig } from "../config.js";
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
const BOOTSTRAP_SCHEMA_VERSION = 7;
const BOOTSTRAP_SCHEMA_VERSION = 8;
export interface ResolvedBot extends BotConfig {
loadedSkills: LoadedSkill[];
@@ -44,13 +44,14 @@ function buildAssistantBootstrap(bot: BotConfig): string {
`Workspace: ${bot.workspace}`,
persona ? `Persona:\n${persona}` : "Persona: general coding assistant",
"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.",
"Speaking style: talk like a reliable colleague, not a console. Lead with the conclusion, then the reason, then the next step. Avoid protocol jargon and field names; never expose internal words like scheduler, awaiting_user_confirmation, envelope, or action types to the user. Default to 2-4 sentences. Do not repeat proposal IDs unless the user asks. If you are unsure, say so plainly. When something is blocked, always give the user an actionable next step.",
"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.",
"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.",
"Never claim an action has already taken effect. The runtime executes your actions after your reply and appends a correction to your message when something could not be done (for example when start_next is blocked by another proposal). Treat that correction as the truth and use the [Proposal states], [Scheduler state] and [Worker state] sections in each prompt as the only reliable state.",
"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."
"Speaking style: talk like a reliable colleague, not a console. Lead with the conclusion, then the reason, then the next step. Avoid protocol jargon and field names; never expose internal words like scheduler, pending, envelope, or action types to the user. Default to 2-4 sentences. Do not repeat proposal IDs unless the user asks. If you are unsure, say so plainly. When something is blocked, always give the user an actionable next step.",
"Every reply must end with exactly one hidden action envelope: <GORI_ASSISTANT_ACTION_V2>{\"reply\":\"...\",\"actions\":[...]}</GORI_ASSISTANT_ACTION_V2>. 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\"}; {\"type\":\"adjust_proposal\",\"id\":\"optional\",\"title\":\"optional\",\"goal\":\"optional\",\"steps\":\"optional\"}; {\"type\":\"follow_up\",\"id\":\"optional\",\"instruction\":\"...\"}; {\"type\":\"finish\",\"id\":\"optional\",\"note\":\"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. When a worker finishes a turn, the proposal becomes pending: it waits for the user's decision with a summary (and maybe a question). The worker never reports success or failure; treat every result as information for the user. \"finish\" closes a pending proposal as done; \"follow_up\" sends the user's new instruction to the same proposal and resumes its worker; \"cancel\" drops a proposed, queued, or pending proposal; \"stop\" aborts the running worker and leaves the proposal pending; \"start_next\" starts the oldest confirmed queued proposal. \"adjust_proposal\" edits a proposal that has not started yet. Never invent other actions or statuses.",
"Whenever the [Pending proposals] section in a prompt lists one of the user's proposals, your reply MUST acknowledge it: remind the user what is waiting and that they can say finish to close it or just keep talking to continue it.",
"Never claim an action has already taken effect. The runtime executes your actions after your reply and appends a correction to your message when something could not be done (for example when start_next is blocked by another proposal). Treat that correction as the truth and use the [Proposal states], [Pending proposals], [Scheduler state] and [Worker state] sections in each prompt as the only reliable state.",
"In a group chat each proposal is owned by the user who requested it: only that user can confirm, adjust, follow up, finish, stop, or cancel it. If another group member asks you to operate a proposal they did not create, explain that only the proposal's initiator can do that and emit no action.",
"When a proposal is pending and the user says something like \"够了\", \"可以了\", \"结束吧\" or \"that's enough\", emit a \"finish\" action. When they instead ask for more changes or answer the pending question, emit \"follow_up\" with their instruction so the same worker continues."
].join("\n\n");
}
@@ -62,7 +63,7 @@ function buildWorkerBootstrap(bot: BotConfig, skills: LoadedSkill[]): string {
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
`Permission policy enforced by the ACP client: ${JSON.stringify(bot.permissions)}`,
"You are the Worker. You execute exactly one confirmed proposal in the configured workspace and never talk to the user directly.",
"For every turn, end your response with exactly one hidden result envelope: <GORI_WORKER_RESULT_V1>{\"status\":\"SUCCESS\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V1>. Status is SUCCESS only when the proposal is fully done, FAILED when it cannot be completed, or NEEDS_CONFIRMATION when you must ask the user before continuing (for example a dirty or unexpected target). Optional fields: \"question\", \"nextStep\", \"dirty\". Always include a short user-readable \"summary\". Never emit any other status or text after the envelope.",
"For every turn, end your response with exactly one hidden result envelope: <GORI_WORKER_RESULT_V2>{\"status\":\"PENDING\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V2>. PENDING is the only status: it hands the result back to the user. Always include a short user-readable \"summary\" of what you did or what is blocking you. Add a \"question\" when you need the user's decision before continuing. Set \"workspaceDirty\": true when you left the workspace modified or are unsure about its state. Never emit any other status or text after the envelope.",
"Keep every command and tool process attached to this ACP worker. Never daemonize, call setsid, use nohup, create a detached process, or leave a background process running after the turn.",
...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`)
].join("\n\n");
+37
View File
@@ -1,5 +1,8 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
@@ -51,3 +54,37 @@ test("reports activity for every update from the active session", async () => {
child.kill("SIGTERM");
}
});
test("prompt sends text and image content blocks and records the image capability", async () => {
const logFile = path.join(os.tmpdir(), `gori-acp-client-${crypto.randomUUID()}.log`);
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
const child = spawn(process.execPath, [fixture], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, FAKE_ACP_LOG: logFile }
});
const client = new AcpClient(child, {
initializeTimeoutMs: 1_000,
policy: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
});
try {
await client.initialize();
assert.equal(client.supportsImageInput(), true);
await client.newSession(path.resolve("."));
const image = Buffer.from("fake-png").toString("base64");
const reply = await client.prompt([
{ type: "image", data: image, mimeType: "image/png" },
{ type: "text", text: "hello blocks" }
]);
assert.match(reply, /^reply:.+:hello blocks$/);
const prompts = fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean)
.map((line) => JSON.parse(line) as { method: string; text?: string; images?: number })
.filter((entry) => entry.method === "session/prompt");
assert.equal(prompts.length, 1);
assert.equal(prompts[0]!.text, "hello blocks");
assert.equal(prompts[0]!.images, 1);
} finally {
client.close();
child.kill("SIGTERM");
await fs.promises.rm(logFile, { force: true });
}
});
+283 -121
View File
@@ -9,6 +9,7 @@ import { AssistantManager, assistantKeyFor, parseAssistantActions, parseWorkerRe
import { parseConfig } from "../src/config.js";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
import { ProposalStore } from "../src/core/proposal-store.js";
import type { IncomingAttachment } from "../src/core/types.js";
import { BotProfileResolver } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
@@ -81,7 +82,8 @@ async function closeHarness(harness: Harness): Promise<void> {
await fs.promises.rm(harness.workspace, { recursive: true, force: true });
}
const request = (text: string, userId = "user-1") => ({ platform: "webhook", chatId: "chat-1", userId, text });
const request = (text: string, userId = "user-1", attachments?: IncomingAttachment[]) => ({ platform: "webhook", chatId: "chat-1", userId, text, attachments });
const pngAttachment = (): IncomingAttachment[] => [{ mimeType: "image/png", data: Buffer.from("fake-png").toString("base64"), filename: "fake.png" }];
async function waitFor(condition: () => boolean, timeoutMs = 8_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
@@ -91,7 +93,7 @@ async function waitFor(condition: () => boolean, timeoutMs = 8_000): Promise<voi
}
}
function readLog(logFile: string): { method: string; sessionId?: string; cwd?: string; text?: string }[] {
function readLog(logFile: string): { method: string; sessionId?: string; cwd?: string; text?: string; images?: number }[] {
if (!fs.existsSync(logFile)) return [];
return fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as { method: string });
}
@@ -118,9 +120,10 @@ test("create proposal stays proposed until confirm; confirm starts the worker wi
await harness.manager.prompt(request("stop"));
const stopped = harness.proposals.get(proposal.id)!;
assert.equal(stopped.status, "cancelled");
assert.equal(stopped.status, "pending");
assert.equal(stopped.pending?.summary, "被用户中止");
assert.equal(stopped.pending?.workspaceDirty, true);
assert.equal(stopped.workerProcessGroup, undefined);
assert.ok(stopped.finishedAt);
} finally {
await closeHarness(harness);
}
@@ -159,98 +162,162 @@ test("stop does not deadlock while a worker result is waiting to settle", async
assert.ok(startSettle);
void startSettle();
assert.equal(await stopped, true);
await waitFor(() => harness.proposals.get(proposal.id)!.status === "cancelled");
assert.equal(harness.proposals.get(proposal.id)!.status, "cancelled");
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "被用户中止");
} finally {
await fs.promises.rm(gateFile, { force: true });
await closeHarness(harness);
}
});
test("SUCCESS waits for user confirmation and never auto-completes or auto-starts the next proposal", async () => {
test("pending never auto-finishes or auto-starts the next proposal; only finish settles it", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "success");
await waitFor(() => harness.proposals.get(first.id)!.status === "pending");
assert.equal(harness.proposals.get(first.id)!.pending?.summary, "goal done");
assert.equal(harness.proposals.get(first.id)!.finishedAt, undefined);
assert.equal(harness.proposals.get(first.id)!.finishKind, undefined);
// A new proposal can be queued while the owner has a pending one, but it must not start.
await harness.manager.prompt(request("create proposal: succeed"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "queued");
assert.equal(harness.proposals.get(first.id)!.status, "awaiting_user_confirmation");
assert.equal(harness.proposals.get(first.id)!.status, "pending");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(first.id)!.status, "completed");
assert.ok(harness.proposals.get(first.id)!.finishedAt);
await harness.manager.prompt(request("finish"));
const finishedFirst = harness.proposals.get(first.id)!;
assert.equal(finishedFirst.status, "finished");
assert.equal(finishedFirst.finishKind, "done");
assert.ok(finishedFirst.finishedAt);
assert.equal(finishedFirst.pending, undefined);
assert.equal(finishedFirst.lastWorkerSummary, "goal done");
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await harness.manager.prompt(request("start next"));
assert.equal(harness.proposals.get(second.id)!.status, "working");
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "completed");
await waitFor(() => harness.proposals.get(second.id)!.status === "pending");
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-1"), true);
assert.equal(harness.proposals.get(second.id)!.status, "finished");
} finally {
await closeHarness(harness);
}
});
test("FAILED requires confirmation; confirm ends it and confirm retry restarts a working worker", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: fail"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "failure");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(first.id)!.status, "failed");
assert.ok(harness.proposals.get(first.id)!.finishedAt);
await harness.manager.prompt(request("create proposal: fail"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
await harness.manager.prompt(request("confirm retry"));
assert.equal(harness.proposals.get(second.id)!.status, "working");
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(second.id)!.pending?.kind, "failure");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "failed");
} finally {
await closeHarness(harness);
}
});
test("NEEDS_CONFIRMATION answer continues the same worker session", async () => {
test("follow_up on a pending proposal resumes the same worker session and carries image attachments", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: ask"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation");
const pendingStep = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pendingStep.kind, "step");
assert.equal(pendingStep.question, "May I overwrite it?");
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const pendingCard = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pendingCard.question, "May I overwrite it?");
assert.equal(pendingCard.workspaceDirty, true);
const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!;
assert.ok(workerSessionId);
await harness.manager.prompt(request("answer: yes, overwrite it"));
await waitFor(() => harness.proposals.get(proposal.id)!.pending?.kind === "success");
await harness.manager.prompt(request("follow up: yes, overwrite it", "user-1", pngAttachment()));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const continued = harness.proposals.get(proposal.id)!;
assert.equal(continued.workerNativeSessionId, workerSessionId);
assert.match(continued.lastWorkerSummary || "", new RegExp(`continued ${workerSessionId}`));
assert.equal(continued.pending?.question, undefined);
assert.equal(continued.pending?.workspaceDirty, undefined);
const workerPrompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text && (entry.text.startsWith("Execute this confirmed proposal") || entry.text.startsWith("The user answered your question")));
assert.equal(workerPrompts.length, 2);
assert.ok(workerPrompts.every((entry) => entry.sessionId === workerSessionId));
const resumeEntries = readLog(harness.logFile).filter((entry) => entry.method === "session/resume" && entry.sessionId === workerSessionId);
assert.ok(resumeEntries.length > 0);
const followUpTurns = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("The user sent a follow-up instruction"));
assert.equal(followUpTurns.length, 1);
assert.equal(followUpTurns[0]!.sessionId, workerSessionId);
assert.equal(followUpTurns[0]!.images, 1);
await harness.manager.prompt(request("finish"));
assert.equal(harness.proposals.get(proposal.id)!.status, "finished");
assert.equal(harness.proposals.get(proposal.id)!.finishKind, "done");
} finally {
await closeHarness(harness);
}
});
test("follow_up falls back to a fresh worker session with proposal context when resume fails", async () => {
const harness = await createHarness({ agentEnv: { FAKE_ACP_RESUME_FAIL: "1", FAKE_ACP_LOAD_FAIL: "1" } });
try {
await harness.manager.prompt(request("create proposal: ask"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!;
await harness.manager.prompt(request("follow up: yes, overwrite it"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const continued = harness.proposals.get(proposal.id)!;
assert.ok(continued.workerNativeSessionId);
assert.notEqual(continued.workerNativeSessionId, workerSessionId);
assert.match(continued.lastWorkerSummary || "", /continued/);
const followUpTurns = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("The user sent a follow-up instruction"));
assert.equal(followUpTurns.length, 1);
assert.match(followUpTurns[0]!.text || "", /fresh session continuing the same proposal/);
} finally {
await closeHarness(harness);
}
});
test("a fresh non-finish message reminds about the pending proposal when the assistant forgets it", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const forgotten = await harness.manager.prompt(request("hello"));
assert.match(forgotten.text, /待确认的任务「Test proposal」/);
assert.match(forgotten.text, /finish 结束/);
const acknowledged = await harness.manager.prompt(request("ack pending"));
assert.match(acknowledged.text, /任务「Test proposal」还在等你确认/);
assert.doesNotMatch(acknowledged.text, /提醒:你还有/);
const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello"));
assert.equal(prompts.length, 1);
assert.match(prompts[0]!.text!, /\[Pending proposals\]/);
assert.match(prompts[0]!.text!, /summary="goal done"/);
} finally {
await closeHarness(harness);
}
});
test("adjust_proposal edits proposed or queued proposals but not pending ones", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("adjust proposal: Better title"));
assert.equal(harness.proposals.get(first.id)!.title, "Better title");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(proposal.id)!.status, "completed");
await waitFor(() => harness.proposals.get(first.id)!.status === "pending");
await harness.manager.prompt(request("create proposal: succeed"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await harness.manager.prompt(request("adjust proposal: Queued title"));
assert.equal(harness.proposals.get(second.id)!.title, "Queued title");
// With nothing proposed or queued left, the pending proposal cannot be adjusted.
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-1"), true);
assert.equal(harness.proposals.get(second.id)!.status, "finished");
const rejected = await harness.manager.prompt(request("adjust proposal: Nope"));
assert.match(rejected.text, /没有可调整的提案/);
assert.equal(harness.proposals.get(first.id)!.title, "Better title");
} finally {
await closeHarness(harness);
}
@@ -275,7 +342,7 @@ test("worker completion notifies the owner chat through the event sink", async (
await harness.manager.prompt(request("create proposal: succeed"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation");
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
await waitFor(() => harness.events.length > 0);
assert.equal(harness.events[0]!.chatKey, CHAT_KEY);
assert.match(harness.events[0]!.text, /event received: worker update/);
@@ -284,7 +351,7 @@ test("worker completion notifies the owner chat through the event sink", async (
}
});
test("restart recovery kills the persisted worker group and marks the working proposal failed as worker_lost", async () => {
test("restart recovery kills the persisted worker group and marks the working proposal pending as worker_lost", async () => {
const harness = await createHarness();
const token = crypto.randomUUID();
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
@@ -301,10 +368,10 @@ test("restart recovery kills the persisted worker group and marks the working pr
await reopenManager(harness);
const recovered = harness.proposals.get(proposal.id)!;
assert.equal(recovered.status, "failed");
assert.equal(recovered.status, "pending");
assert.match(recovered.pending?.summary || "", /worker_lost/);
assert.equal(recovered.pending?.workspaceDirty, true);
assert.match(recovered.lastWorkerSummary || "", /worker_lost/);
assert.ok(recovered.finishedAt);
await waitFor(() => !processGroupExists(child.pid!));
} finally {
try { process.kill(-child.pid!, "SIGKILL"); } catch { /* already dead */ }
@@ -312,7 +379,7 @@ 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 () => {
test("proposals are owned per chat+user: a second user in the same chat cannot confirm, finish, stop, cancel, or list them", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: hang", "user-a"));
@@ -328,6 +395,7 @@ test("proposals are owned per chat+user: a second user in the same chat cannot c
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.finish("webhook", "chat-1", "user-b"), false);
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);
@@ -344,7 +412,8 @@ test("proposals are owned per chat+user: a second user in the same chat cannot c
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");
assert.equal(harness.proposals.get(proposal.id)!.status, "pending");
assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "被用户中止");
} finally {
await closeHarness(harness);
}
@@ -356,68 +425,47 @@ test("start_next only starts the requesting user's own queued proposal", async (
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 waitFor(() => harness.proposals.get(first.id)!.status === "pending");
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");
await harness.manager.prompt(request("finish", "user-a"));
assert.equal(harness.proposals.get(first.id)!.status, "finished");
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");
await waitFor(() => harness.proposals.get(second.id)!.status === "pending");
} finally {
await closeHarness(harness);
}
});
test("stop settles the latest awaiting proposal (success completes, failure fails, step cancels) without auto-starting the next", async () => {
test("cancel settles proposed, queued, or pending proposals as cancelled and keeps finished history", 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");
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(first.id)!.status, "finished");
assert.equal(harness.proposals.get(first.id)!.finishKind, "cancelled");
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 waitFor(() => harness.proposals.get(second.id)!.status === "pending");
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-a"), true);
const cancelled = harness.proposals.get(second.id)!;
assert.equal(cancelled.status, "finished");
assert.equal(cancelled.finishKind, "cancelled");
assert.ok(cancelled.finishedAt);
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);
const finished = harness.manager.listProposals("webhook", "chat-1", "user-a");
assert.equal(finished.length, 2);
assert.ok(finished.every((proposal) => proposal.status === "finished"));
} finally {
await closeHarness(harness);
}
@@ -425,25 +473,112 @@ test("stop settles the latest awaiting proposal (success completes, failure fail
test("envelope parsers accept valid tails and reject invalid ones", () => {
assert.deepEqual(
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"stop"}]}</GORI_ASSISTANT_ACTION_V1>`),
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"stop"}]}</GORI_ASSISTANT_ACTION_V2>`),
{ reply: "hi", actions: [{ type: "stop" }] }
);
assert.equal(parseAssistantActions("no envelope"), undefined);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"nuke"}]}</GORI_ASSISTANT_ACTION_V1>`), undefined);
assert.deepEqual(
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V1>{"status":"NEEDS_CONFIRMATION","summary":"s","question":"q","dirty":true}</GORI_WORKER_RESULT_V1>`),
{ status: "NEEDS_CONFIRMATION", summary: "s", question: "q", nextStep: undefined, dirty: true }
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"follow_up","instruction":"keep going"},{"type":"finish","note":"done"}]}</GORI_ASSISTANT_ACTION_V2>`),
{ reply: "hi", actions: [{ type: "follow_up", id: undefined, instruction: "keep going" }, { type: "finish", id: undefined, note: "done" }] }
);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V1>{"status":"DONE","summary":"s"}</GORI_WORKER_RESULT_V1>`), undefined);
assert.equal(parseAssistantActions("no envelope"), undefined);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"nuke"}]}</GORI_ASSISTANT_ACTION_V2>`), undefined);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[]}</GORI_ASSISTANT_ACTION_V1>`), undefined);
assert.deepEqual(
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","question":"q","workspaceDirty":true}</GORI_WORKER_RESULT_V2>`),
{ status: "PENDING", summary: "s", question: "q", workspaceDirty: true }
);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V2>{"status":"SUCCESS","summary":"s"}</GORI_WORKER_RESULT_V2>`), undefined);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V1>{"status":"PENDING","summary":"s"}</GORI_WORKER_RESULT_V1>`), undefined);
});
test("start_next blocked by another user's pending confirmation appends a correction and exposes no details", async () => {
test("an owner's own pending proposal blocks their start_next with an actionable correction", 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 waitFor(() => harness.proposals.get(first.id)!.status === "pending");
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");
const blocked = await harness.manager.prompt(request("start next", "user-a"));
assert.match(blocked.text, /还不能开始/);
assert.match(blocked.text, /先 finish 或直接继续说要求/);
assert.match(blocked.text, /保留在队列里/);
assert.equal(harness.proposals.get(second.id)!.status, "queued");
const status = harness.manager.status("webhook", "chat-1", "user-a");
assert.equal(status.schedulerState, "pending");
assert.equal(status.blockedReason, "your proposal is waiting for your decision");
assert.equal(status.myPendingProposals, 1);
assert.equal(status.myQueuedProposals, 1);
assert.equal(status.nextAction, "finish your pending proposal or follow up with new instructions");
} finally {
await closeHarness(harness);
}
});
test("a clean pending proposal does not block other users, but a dirty one blocks everyone", async () => {
const harness = await createHarness();
try {
// user-a's clean pending: user-b's confirmed proposal starts immediately.
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const clean = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(clean.id)!.status === "pending");
assert.equal(harness.proposals.get(clean.id)!.pending?.workspaceDirty, undefined);
await harness.manager.prompt(request("create proposal: succeed", "user-b"));
const other = harness.proposals.list().find((proposal) => proposal.id !== clean.id)!;
await harness.manager.prompt(request("confirm", "user-b"));
assert.equal(harness.proposals.get(other.id)!.status, "working");
await waitFor(() => harness.proposals.get(other.id)!.status === "pending");
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-b"), true);
await harness.manager.prompt(request("finish", "user-a"));
assert.equal(harness.proposals.get(clean.id)!.status, "finished");
// user-a's dirty pending: user-b's start_next is globally blocked.
await harness.manager.prompt(request("create proposal: ask", "user-a"));
const dirty = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(dirty.id)!.status === "pending");
assert.equal(harness.proposals.get(dirty.id)!.pending?.workspaceDirty, true);
await harness.manager.prompt(request("create proposal: succeed", "user-b"));
const blockedProposal = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
await harness.manager.prompt(request("confirm", "user-b"));
assert.equal(harness.proposals.get(blockedProposal.id)!.status, "queued");
const blocked = await harness.manager.prompt(request("start next", "user-b"));
assert.match(blocked.text, /还不能开始/);
assert.match(blocked.text, /dirty 工作区/);
assert.doesNotMatch(blocked.text, new RegExp(dirty.title));
assert.equal(harness.proposals.get(blockedProposal.id)!.status, "queued");
const bStatus = harness.manager.status("webhook", "chat-1", "user-b");
assert.equal(bStatus.schedulerState, "pending");
assert.equal(bStatus.blockedReason, "a pending proposal left a dirty workspace");
await harness.manager.prompt(request("finish", "user-a"));
assert.equal(harness.proposals.get(dirty.id)!.status, "finished");
await harness.manager.prompt(request("start next", "user-b"));
assert.equal(harness.proposals.get(blockedProposal.id)!.status, "working");
await waitFor(() => harness.proposals.get(blockedProposal.id)!.status === "pending");
} finally {
await closeHarness(harness);
}
});
test("a working proposal blocks start_next for everyone with a sanitized reason", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: hang", "user-a"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(first.id)!.status === "working");
await harness.manager.prompt(request("create proposal: succeed", "user-b"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
@@ -452,33 +587,27 @@ test("start_next blocked by another user's pending confirmation appends a correc
const blocked = await harness.manager.prompt(request("start next", "user-b"));
assert.match(blocked.text, /还不能开始/);
assert.match(blocked.text, /发起人确认/);
assert.match(blocked.text, /保留在队列里/);
assert.match(blocked.text, /正在执行/);
assert.equal(harness.proposals.get(second.id)!.status, "queued");
assert.equal(harness.proposals.get(first.id)!.status, "awaiting_user_confirmation");
const bPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nstart next"));
assert.ok(bPrompt?.text);
assert.match(bPrompt.text!, /blocked: another proposal is awaiting owner confirmation/);
assert.doesNotMatch(bPrompt.text!, /goal done/);
assert.match(bPrompt.text!, /busy: another proposal is working/);
const bStatus = harness.manager.status("webhook", "chat-1", "user-b");
assert.equal(bStatus.schedulerState, "awaiting_confirmation");
assert.equal(bStatus.blockedReason, "another proposal is awaiting owner confirmation");
assert.equal(bStatus.myQueuedProposals, 1);
assert.equal(bStatus.myPendingConfirmations, 0);
assert.equal(bStatus.schedulerState, "working");
assert.equal(bStatus.blockedReason, "another proposal is working");
assert.equal(bStatus.nextAction, "wait for the current proposal to settle");
const aStatus = harness.manager.status("webhook", "chat-1", "user-a");
assert.equal(aStatus.myPendingConfirmations, 1);
assert.equal(aStatus.blockedReason, "your proposal is awaiting your confirmation");
assert.equal(aStatus.nextAction, "confirm your pending proposal");
// follow_up is also globally blocked while a worker is running.
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(first.id)!.status, "pending");
} finally {
await closeHarness(harness);
}
});
test("confirm and stop actions with no matching proposal append an ownership correction", async () => {
test("confirm, finish, stop, cancel, and follow_up actions with no matching proposal append an ownership correction", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: hang", "user-a"));
@@ -487,6 +616,10 @@ test("confirm and stop actions with no matching proposal append an ownership cor
assert.match(confirmed.text, /没有可确认的提案/);
assert.match(confirmed.text, /自己发起/);
const finished = await harness.manager.prompt(request("finish", "user-b"));
assert.match(finished.text, /没有可结束的任务/);
assert.match(finished.text, /自己发起/);
const stopped = await harness.manager.prompt(request("stop", "user-b"));
assert.match(stopped.text, /没有可停止的任务/);
assert.match(stopped.text, /自己发起/);
@@ -495,6 +628,10 @@ test("confirm and stop actions with no matching proposal append an ownership cor
assert.match(cancelled.text, /没有可取消的提案/);
assert.match(cancelled.text, /自己发起/);
const followedUp = await harness.manager.prompt(request("follow up: more", "user-b"));
assert.match(followedUp.text, /现在没法继续/);
assert.match(followedUp.text, /自己发起/);
assert.equal(harness.proposals.list()[0]!.status, "proposed");
} finally {
await closeHarness(harness);
@@ -516,6 +653,31 @@ test("start_next reports when the queue is empty instead of claiming a start", a
}
});
test("image attachments reach the assistant as image blocks when the agent supports them", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("hello", "user-1", pngAttachment()));
const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello"));
assert.equal(prompts.length, 1);
assert.equal(prompts[0]!.images, 1);
} finally {
await closeHarness(harness);
}
});
test("image attachments degrade to a text note when the agent has no image capability", async () => {
const harness = await createHarness({ agentEnv: { FAKE_ACP_IMAGE_CAP: "0" } });
try {
await harness.manager.prompt(request("hello", "user-1", pngAttachment()));
const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello"));
assert.equal(prompts.length, 1);
assert.equal(prompts[0]!.images, 0);
assert.match(prompts[0]!.text!, /\[图片,当前 agent 不支持图片输入\]/);
} finally {
await closeHarness(harness);
}
});
function processGroupExists(pgid: number): boolean {
try { process.kill(-pgid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
+21 -14
View File
@@ -7,15 +7,19 @@ import * as acp from "@agentclientprotocol/sdk";
const pending = new Map();
const logFile = process.env.FAKE_ACP_LOG;
const log = (entry) => { if (logFile) fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`); };
const assistantEnvelope = (reply, actions) => `${reply}\n<GORI_ASSISTANT_ACTION_V1>${JSON.stringify({ reply, actions })}</GORI_ASSISTANT_ACTION_V1>`;
const workerEnvelope = (result) => `worker reply\n<GORI_WORKER_RESULT_V1>${JSON.stringify(result)}</GORI_WORKER_RESULT_V1>`;
const assistantEnvelope = (reply, actions) => `${reply}\n<GORI_ASSISTANT_ACTION_V2>${JSON.stringify({ reply, actions })}</GORI_ASSISTANT_ACTION_V2>`;
const workerEnvelope = (result) => `worker reply\n<GORI_WORKER_RESULT_V2>${JSON.stringify(result)}</GORI_WORKER_RESULT_V2>`;
const app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.initialize, ({ params }) => {
log({ method: "initialize" });
return {
protocolVersion: params.protocolVersion,
agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {}, close: {}, list: {} } },
agentCapabilities: {
loadSession: true,
promptCapabilities: { image: process.env.FAKE_ACP_IMAGE_CAP !== "0" },
sessionCapabilities: { resume: {}, close: {}, list: {} }
},
agentInfo: { name: "fake-acp-agent", version: "1" }
};
})
@@ -39,7 +43,8 @@ const app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.session.list, () => ({ sessions: [] }))
.onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => {
const text = params.prompt.filter((item) => item.type === "text").map((item) => item.text).join("");
log({ method: "session/prompt", sessionId: params.sessionId, text });
const images = params.prompt.filter((item) => item.type === "image").length;
log({ method: "session/prompt", sessionId: params.sessionId, text, images });
if (text === "crash" || text.startsWith("crash\n\nWhen this turn")) process.exit(9);
if (text.includes("Initialize this ACP session")) {
if (process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS) await new Promise((resolve) => setTimeout(resolve, Number(process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS)));
@@ -48,14 +53,14 @@ const app = acp.agent({ name: "fake-acp-agent" })
}
// Assistant protocol
if (text.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V1")) {
if (text.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V2")) {
await update(client, params.sessionId, assistantEnvelope("repaired", []));
return { stopReason: "end_turn" };
}
if (text.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V1")) {
if (text.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V2")) {
await update(client, params.sessionId, process.env.FAKE_ACP_INVALID_REPAIR === "1"
? "still invalid"
: workerEnvelope({ status: "SUCCESS", summary: "repaired" }));
: workerEnvelope({ status: "PENDING", summary: "repaired" }));
return { stopReason: "end_turn" };
}
if (text.includes("[Internal event")) {
@@ -76,11 +81,13 @@ const app = acp.agent({ name: "fake-acp-agent" })
const goal = userText.slice("create proposal:".length).trim();
response = assistantEnvelope("proposal drafted", [{ type: "create_proposal", title: "Test proposal", goal, steps: ["step 1"] }]);
} else if (userText === "confirm") response = assistantEnvelope("confirmed", [{ type: "confirm" }]);
else if (userText === "confirm retry") response = assistantEnvelope("retrying", [{ type: "confirm", answer: "retry" }]);
else if (userText.startsWith("answer:")) response = assistantEnvelope("answering", [{ type: "confirm", answer: userText.slice("answer:".length).trim() }]);
else if (userText === "finish") response = assistantEnvelope("finishing", [{ type: "finish" }]);
else if (userText.startsWith("follow up:")) response = assistantEnvelope("following up", [{ type: "follow_up", instruction: userText.slice("follow up:".length).trim() }]);
else if (userText.startsWith("adjust proposal:")) response = assistantEnvelope("adjusting", [{ type: "adjust_proposal", title: userText.slice("adjust proposal:".length).trim() }]);
else if (userText === "start next") response = assistantEnvelope("starting next", [{ type: "start_next" }]);
else if (userText === "stop") response = assistantEnvelope("stopping", [{ type: "stop" }]);
else if (userText === "cancel proposal") response = assistantEnvelope("cancelling", [{ type: "cancel" }]);
else if (userText === "ack pending") response = assistantEnvelope("任务「Test proposal」还在等你确认。", []);
else if (userText === "invalid assistant") response = "invalid without envelope";
else response = assistantEnvelope("ok", []);
await update(client, params.sessionId, response);
@@ -88,8 +95,8 @@ const app = acp.agent({ name: "fake-acp-agent" })
}
// Worker protocol
if (text.startsWith("The user answered your question")) {
await update(client, params.sessionId, workerEnvelope({ status: "SUCCESS", summary: `continued ${params.sessionId}` }));
if (text.startsWith("The user sent a follow-up instruction")) {
await update(client, params.sessionId, workerEnvelope({ status: "PENDING", summary: `continued ${params.sessionId}` }));
return { stopReason: "end_turn" };
}
if (text.startsWith("Execute this confirmed proposal")) {
@@ -111,10 +118,10 @@ const app = acp.agent({ name: "fake-acp-agent" })
while (!fs.existsSync(process.env.FAKE_ACP_WORKER_GATE_FILE)) await new Promise((resolve) => setTimeout(resolve, 5));
}
const result = goal.includes("ask")
? { status: "NEEDS_CONFIRMATION", summary: "hit a dirty target", question: "May I overwrite it?", nextStep: "overwrite it", dirty: true }
? { status: "PENDING", summary: "hit a dirty target", question: "May I overwrite it?", workspaceDirty: true }
: goal.includes("fail")
? { status: "FAILED", summary: "something broke" }
: { status: "SUCCESS", summary: "goal done" };
? { status: "PENDING", summary: "something broke" }
: { status: "PENDING", summary: "goal done" };
await update(client, params.sessionId, workerEnvelope(result));
return { stopReason: "end_turn" };
}
+41 -4
View File
@@ -31,10 +31,12 @@ class FakeRuntime implements ConversationRuntime {
readonly turns: PendingTurn[] = [];
cancelled = 0;
confirmed = 0;
finished = 0;
stopped = 0;
listed = 0;
cancelResult = true;
confirmResult = true;
finishResult = true;
stopResult = true;
proposals: Proposal[] = [];
commandUsers: Record<string, string | undefined> = {};
@@ -50,6 +52,7 @@ class FakeRuntime implements ConversationRuntime {
}
async cancel(_platform: string, _chatId: string, userId: string) { this.cancelled++; this.commandUsers.cancel = userId; return this.cancelResult; }
async confirm(_platform: string, _chatId: string, userId: string) { this.confirmed++; this.commandUsers.confirm = userId; return this.confirmResult; }
async finish(_platform: string, _chatId: string, userId: string) { this.finished++; this.commandUsers.finish = userId; return this.finishResult; }
async stop(_platform: string, _chatId: string, userId: string) { this.stopped++; this.commandUsers.stop = userId; return this.stopResult; }
listProposals(_platform: string, _chatId: string, userId: string) { this.listed++; this.commandUsers.list = userId; return this.proposals; }
async reset() {}
@@ -434,20 +437,22 @@ test("status and help reach the runtime or reply directly", async () => {
const help = await gateway.receive(message("/help"), adapter, { synchronous: true });
assert.match(help.reply || "", /自然语言/);
assert.match(help.reply || "", /\/confirm/);
assert.match(help.reply || "", /\/finish/);
assert.match(help.reply || "", /\/stop/);
assert.match(help.reply || "", /\/cancel/);
runtime.turns[0].resolve("done");
await turn;
});
test("list, confirm, stop, and cancel forward to the runtime with the chat identity", async () => {
test("list, confirm, finish, stop, and cancel forward to the runtime with the chat identity", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
runtime.proposals = [fakeProposal()];
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
assert.equal(runtime.listed, 1);
assert.match(list.reply || "", /proposal-1 \[proposed\] 修复登录页/);
assert.match(list.reply || "", /未确认(proposed)/);
assert.match(list.reply || "", /proposal-1「修复登录页」/);
runtime.proposals = [];
const emptyList = await gateway.receive(message("/list"), adapter, { synchronous: true });
@@ -455,11 +460,15 @@ test("list, confirm, stop, and cancel forward to the runtime with the chat ident
const confirm = await gateway.receive(message("/confirm"), adapter, { synchronous: true });
assert.equal(runtime.confirmed, 1);
assert.equal(confirm.reply, "已确认,继续执行。");
assert.equal(confirm.reply, "已确认,加入队列。");
const finish = await gateway.receive(message("/finish"), adapter, { synchronous: true });
assert.equal(runtime.finished, 1);
assert.equal(finish.reply, "已结束该任务。");
const stop = await gateway.receive(message("/stop"), adapter, { synchronous: true });
assert.equal(runtime.stopped, 1);
assert.equal(stop.reply, "已停止当前任务。");
assert.equal(stop.reply, "已停止当前任务,任务转为待确认。");
const cancel = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
assert.equal(runtime.cancelled, 1);
@@ -467,17 +476,45 @@ test("list, confirm, stop, and cancel forward to the runtime with the chat ident
assert.equal(runtime.commandUsers.list, "user");
assert.equal(runtime.commandUsers.confirm, "user");
assert.equal(runtime.commandUsers.finish, "user");
assert.equal(runtime.commandUsers.stop, "user");
assert.equal(runtime.commandUsers.cancel, "user");
runtime.confirmResult = false;
runtime.finishResult = false;
runtime.stopResult = false;
runtime.cancelResult = false;
assert.equal((await gateway.receive(message("/confirm"), adapter, { synchronous: true })).reply, "当前没有待确认的提案。");
assert.equal((await gateway.receive(message("/finish"), adapter, { synchronous: true })).reply, "当前没有待结束的任务。");
assert.equal((await gateway.receive(message("/stop"), adapter, { synchronous: true })).reply, "当前没有正在执行的任务。");
assert.equal((await gateway.receive(message("/cancel"), adapter, { synchronous: true })).reply, "没有可取消的提案。");
});
test("the list panel orders pending first, then working, queued, proposed, and recent finished", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
runtime.proposals = [
fakeProposal({ id: "p-finished", title: "旧任务", status: "finished", finishKind: "done", finishedAt: 10 }),
fakeProposal({ id: "p-proposed", title: "新想法", status: "proposed" }),
fakeProposal({ id: "p-working", title: "干活中", status: "working" }),
fakeProposal({ id: "p-queued", title: "排队任务", status: "queued" }),
fakeProposal({ id: "p-pending", title: "待确认任务", status: "pending", pending: { summary: "做了一半", question: "继续吗?", receivedAt: 5 } }),
fakeProposal({ id: "p-cancelled", title: "取消任务", status: "finished", finishKind: "cancelled", finishedAt: 20 })
];
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
const reply = list.reply || "";
const order = ["待确认(等你处理)", "进行中", "排队中", "未确认(proposed)", "最近结束"]
.map((section) => reply.indexOf(section));
assert.ok(order.every((index) => index >= 0), reply);
assert.deepEqual([...order].sort((a, b) => a - b), order);
assert.match(reply, /p-pending「待确认任务」:做了一半(问你:继续吗?)/);
assert.match(reply, /\/finish 结束/);
assert.match(reply, /p-working「干活中」正在执行/);
assert.match(reply, /p-cancelled「取消任务」已取消/);
assert.match(reply, /p-finished「旧任务」已完成/);
});
test("synchronous work sends no delivery messages", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
+129 -12
View File
@@ -21,30 +21,39 @@ test("creates, updates and reloads proposals with 0600 atomic file", async () =>
const confirmedAt = created.createdAt + 1000;
const updated = await first.update(created.id, {
status: "working",
status: "pending",
confirmedAt,
startedAt: confirmedAt + 1,
workerNativeSessionId: "native-1",
workerProcessGroup: { pgid: 4321, token: "worker-token" },
pending: { kind: "step", summary: "needs dirty confirm", question: "overwrite?", nextStep: "edit file" },
pending: { summary: "needs dirty confirm", question: "overwrite?", workspaceDirty: true, receivedAt: confirmedAt + 2 },
lastWorkerSummary: "half done"
});
assert.equal(updated.status, "working");
assert.equal(updated.status, "pending");
assert.ok(updated.updatedAt >= created.updatedAt);
const finished = await first.update(created.id, {
status: "finished",
finishKind: "done",
finishNote: "wrapped up",
pending: undefined,
finishedAt: confirmedAt + 3
});
assert.equal(finished.finishKind, "done");
await first.close();
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
assert.equal(persisted.version, 1); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
assert.equal(persisted.version, 2); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
assert.equal(persisted.proposals[created.id].workerProcessGroup.pgid, 4321);
const second = new ProposalStore(file, identity);
await second.open();
const loaded = second.get(created.id);
assert.deepEqual(loaded, updated);
assert.deepEqual(loaded, finished);
assert.deepEqual(second.list().map((proposal) => proposal.id), [created.id]);
assert.deepEqual(second.list({ status: "working" }).map((proposal) => proposal.id), [created.id]);
assert.deepEqual(second.list({ status: "finished" }).map((proposal) => proposal.id), [created.id]);
assert.equal(second.list({ status: "queued" }).length, 0);
assert.equal(second.stats().proposals, 1);
await second.close();
@@ -73,7 +82,9 @@ test("validates proposal fields on create and update", async () => {
const created = await store.create(input);
await assert.rejects(store.update(created.id, { status: "bogus" as never }), /status/);
await assert.rejects(store.update(created.id, { workerProcessGroup: { pgid: 1, token: "t" } }), /workerProcessGroup/);
await assert.rejects(store.update(created.id, { pending: { kind: "bogus", summary: "s" } as never }), /pending/);
await assert.rejects(store.update(created.id, { pending: { summary: "s" } as never }), /pending/);
await assert.rejects(store.update(created.id, { pending: { summary: "s", receivedAt: "now" } as never }), /pending/);
await assert.rejects(store.update(created.id, { finishKind: "bogus" as never }), /finishKind/);
await assert.rejects(store.update("missing", { status: "queued" }), /unknown proposal/);
assert.equal(store.get(created.id)?.status, "proposed");
await store.close();
@@ -82,24 +93,130 @@ test("validates proposal fields on create and update", async () => {
test("rejects identity or version mismatch and preserves corrupt state", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-id-"));
const file = path.join(dir, "proposals.json");
const wrongVersion = '{"version":2,"botId":"test-bot","platform":"qq","proposals":{}}\n';
const wrongVersion = '{"version":3,"botId":"test-bot","platform":"qq","proposals":{}}\n';
await fs.promises.writeFile(file, wrongVersion);
await assert.rejects(new ProposalStore(file, identity).open(), /expected version 1/);
await assert.rejects(new ProposalStore(file, identity).open(), /expected version 2/);
assert.equal(await fs.promises.readFile(file, "utf8"), wrongVersion);
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "other", platform: "qq", proposals: {} }));
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "other", platform: "qq", proposals: {} }));
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "test-bot", platform: "feishu", proposals: {} }));
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "feishu", proposals: {} }));
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, "not-json");
await assert.rejects(new ProposalStore(file, identity).open(), /original file was preserved/);
assert.equal(await fs.promises.readFile(file, "utf8"), "not-json");
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "test-bot", platform: "qq", proposals: { abc: { id: "other" } } }));
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", proposals: { abc: { id: "other" } } }));
await assert.rejects(new ProposalStore(file, identity).open(), /invalid proposal/);
});
function v1Proposal(id: string, status: string, extra: Record<string, unknown> = {}) {
return {
id,
title: `title-${id}`,
goal: "goal",
steps: ["step"],
ownerChatKey: "qq:chat",
requesterUserId: "user-1",
status,
createdAt: 1,
updatedAt: 2,
...extra
};
}
test("migrates a v1 store: backs up the original file and maps every legacy status", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-migrate-"));
const file = path.join(dir, "proposals.json");
const v1 = {
version: 1,
botId: "test-bot",
platform: "qq",
proposals: {
success: v1Proposal("success", "awaiting_user_confirmation", { pending: { kind: "success", summary: "all done" } }),
failure: v1Proposal("failure", "awaiting_user_confirmation", { pending: { kind: "failure", summary: "it broke" } }),
step: v1Proposal("step", "awaiting_user_confirmation", { pending: { kind: "step", summary: "dirty target", question: "overwrite?", nextStep: "edit" } }),
done: v1Proposal("done", "completed", { finishedAt: 10 }),
broken: v1Proposal("broken", "failed", { finishedAt: 11, lastWorkerSummary: "worker blew up" }),
dropped: v1Proposal("dropped", "cancelled", { finishedAt: 12 }),
fresh: v1Proposal("fresh", "proposed"),
lined: v1Proposal("lined", "queued", { confirmedAt: 5 }),
busy: v1Proposal("busy", "working", { startedAt: 6, workerNativeSessionId: "native-9", workerProcessGroup: { pgid: 4321, token: "tok" } })
}
};
const raw = `${JSON.stringify(v1, null, 2)}\n`;
await fs.promises.writeFile(file, raw, { mode: 0o600 });
const store = new ProposalStore(file, identity);
await store.open();
const backups = (await fs.promises.readdir(dir)).filter((name) => name.startsWith("proposals.json.v1-") && name.endsWith(".bak"));
assert.equal(backups.length, 1);
assert.equal((await fs.promises.stat(path.join(dir, backups[0]!))).mode & 0o777, 0o600);
assert.equal(await fs.promises.readFile(path.join(dir, backups[0]!), "utf8"), raw);
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
assert.equal(persisted.version, 2);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
const success = store.get("success")!;
assert.equal(success.status, "pending");
assert.deepEqual(success.pending, { summary: "all done", receivedAt: 2 });
assert.equal(success.pending?.workspaceDirty, undefined);
const failure = store.get("failure")!;
assert.equal(failure.status, "pending");
assert.equal(failure.pending?.summary, "it broke");
assert.equal(failure.pending?.workspaceDirty, true);
const step = store.get("step")!;
assert.equal(step.status, "pending");
assert.equal(step.pending?.summary, "dirty target");
assert.equal(step.pending?.question, "overwrite?");
assert.equal((step.pending as unknown as Record<string, unknown>).nextStep, undefined);
const done = store.get("done")!;
assert.equal(done.status, "finished");
assert.equal(done.finishKind, "done");
assert.equal(done.finishedAt, 10);
const broken = store.get("broken")!;
assert.equal(broken.status, "finished");
assert.equal(broken.finishKind, "done");
assert.equal(broken.finishNote, "worker blew up");
const dropped = store.get("dropped")!;
assert.equal(dropped.status, "finished");
assert.equal(dropped.finishKind, "cancelled");
assert.equal(store.get("fresh")!.status, "proposed");
assert.equal(store.get("lined")!.status, "queued");
const busy = store.get("busy")!;
assert.equal(busy.status, "working");
assert.equal(busy.workerNativeSessionId, "native-9");
assert.deepEqual(busy.workerProcessGroup, { pgid: 4321, token: "tok" });
await store.close();
// The migrated store reloads as a plain v2 file without creating another backup.
const reloaded = new ProposalStore(file, identity);
await reloaded.open();
assert.equal(reloaded.stats().proposals, 9);
await reloaded.close();
const backupsAfter = (await fs.promises.readdir(dir)).filter((name) => name.endsWith(".bak"));
assert.equal(backupsAfter.length, 1);
});
test("a v1 store with a mismatched identity is rejected before any backup is written", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-migrate-id-"));
const file = path.join(dir, "proposals.json");
const raw = `${JSON.stringify({ version: 1, botId: "other-bot", platform: "qq", proposals: {} })}\n`;
await fs.promises.writeFile(file, raw, { mode: 0o600 });
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
assert.equal(await fs.promises.readFile(file, "utf8"), raw);
assert.equal((await fs.promises.readdir(dir)).filter((name) => name.endsWith(".bak")).length, 0);
});
test("refuses a second writer and recovers a stale lock", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-lock-"));
const file = path.join(dir, "proposals.json");
+119
View File
@@ -31,6 +31,125 @@ test("group messages without member_openid fall back to user_openid", async () =
assert.equal(received[1].userId, "mem4");
});
test("image attachments are downloaded to base64 while video and file attachments degrade to text links", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
const pngBytes = Buffer.from("fake-png-bytes");
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
urls.push(String(input));
return new Response(pngBytes, { status: 200 });
}) as typeof fetch;
try {
await adapter.handleWebhook({
body: {
op: 0, t: "C2C_MESSAGE_CREATE",
d: {
id: "m10", author: { user_openid: "u10" }, content: "看看这些",
attachments: [
{ content_type: "image/png", filename: "a.png", size: pngBytes.length, url: "//cdn.example.com/a.png" },
{ content_type: "video/mp4", filename: "v.mp4", size: 1024, url: "https://cdn.example.com/v.mp4" },
{ content_type: "application/pdf", filename: "f.pdf", size: 1024, url: "https://cdn.example.com/f.pdf" }
]
}
},
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
const message = received[0];
assert.deepEqual(urls, ["https://cdn.example.com/a.png"]);
assert.equal(message.attachments.length, 1);
assert.equal(message.attachments[0].mimeType, "image/png");
assert.equal(message.attachments[0].filename, "a.png");
assert.equal(message.attachments[0].data, pngBytes.toString("base64"));
assert.match(message.text, /^看看这些/);
assert.match(message.text, /\[视频\] https:\/\/cdn\.example\.com\/v\.mp4/);
assert.match(message.text, /\[文件\] https:\/\/cdn\.example\.com\/f\.pdf/);
} finally { globalThis.fetch = original; }
});
test("oversized images are skipped and images beyond the per-message cap are not downloaded", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
urls.push(String(input));
return new Response(Buffer.from("x"), { status: 200 });
}) as typeof fetch;
try {
await adapter.handleWebhook({
body: {
op: 0, t: "C2C_MESSAGE_CREATE",
d: {
id: "m11", author: { user_openid: "u11" }, content: "hi",
attachments: [
{ content_type: "image/png", size: 6 * 1024 * 1024, url: "https://cdn.example.com/big.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/1.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/2.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/3.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/4.png" }
]
}
},
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
assert.deepEqual(urls, [
"https://cdn.example.com/1.png",
"https://cdn.example.com/2.png",
"https://cdn.example.com/3.png"
]);
assert.equal(received[0].attachments.length, 3);
} finally { globalThis.fetch = original; }
});
test("a pure image message uses placeholder text and failed downloads degrade", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
const original = globalThis.fetch;
let fail = false;
globalThis.fetch = (async () => {
if (fail) throw new Error("network down");
return new Response(Buffer.from("img"), { status: 200 });
}) as typeof fetch;
try {
await adapter.handleWebhook({
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m12", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/a.jpg" }] } },
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
assert.equal(received[0].text, "(发来一张图片)");
assert.equal(received[0].attachments.length, 1);
// Every download failing with no text keeps the message ignored.
fail = true;
await adapter.handleWebhook({
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m13", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/b.jpg" }] } },
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
// Failed downloads with text still deliver the text without attachments.
await adapter.handleWebhook({
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m14", author: { user_openid: "u12" }, content: "看图", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/c.jpg" }] } },
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 2);
assert.equal(received[1].text, "看图");
assert.equal(received[1].attachments, undefined);
} finally { globalThis.fetch = original; }
});
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>> = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {