feat: share finish stop and cancel across the board
Proposal visibility is already global; this change makes finish, stop, and cancel shared queue-management actions so any user can unblock the single worker and shared queue. Confirm, adjust, follow_up, and start_next remain owner-only. Assistant/bootstrap copy, /help, /list, and runtime checks now align with that split.
This commit is contained in:
@@ -207,7 +207,7 @@ Kimi Code agent `args` 必须严格为 `["acp"]`,不能添加可能绕过 assi
|
|||||||
|
|
||||||
## 5. Assistant / Proposal / Worker 与 state v3
|
## 5. Assistant / Proposal / Worker 与 state v3
|
||||||
|
|
||||||
运行链路是三层:每 conversation(chat + user)一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单,owner 是 chat + 发起用户,confirm/adjust/follow_up/finish/stop/cancel 全部 owner-only(runtime 强校验),`start_next` 也只启动发起人自己的 queued Proposal。Proposal 板全局共享可见:Assistant prompt 的全板列表包含所有 chat/user 的未完成条目(`scope=own` / `scope=other` + chat 类型,不暴露 openid),Assistant 可向任何用户如实描述全板,但 action 只能作用于 scope=own 条目。
|
运行链路是三层:每 conversation(chat + user)一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单,owner 是 chat + 发起用户。`confirm` / `adjust` / `follow_up` / `start_next` 维持 owner-only(runtime 强校验);为了避免单人把单 worker 队列卡死,`finish` / `stop` / `cancel` 是全板共享动作,任何用户都可对可见 proposal 执行。Proposal 板全局共享可见:Assistant prompt 的全板列表包含所有 chat/user 的未完成条目(`scope=own` / `scope=other` + chat 类型,不暴露 openid),Assistant 可向任何用户如实描述全板,并可按规则对共享动作发起 action。
|
||||||
|
|
||||||
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 开始。
|
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 开始。
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ Assistant 隔离:cwd 在实例私有 `state/assistant-workspaces/`,目录 ke
|
|||||||
|
|
||||||
state v3(`acp-sessions.json`)header 保存 `botId` 和 platform,只存 assistant binding(agent ID、assistant workspace、native session ID、Bot fingerprint、时间戳);`proposals.json`(version 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 失败不重放。
|
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`、`/finish`、`/stop`、`/cancel` 旁路 Assistant;除 `/list` 外全部 owner-only(chat + user):`/confirm` 对 proposed,`/finish` 对 pending,`/stop` 仅对 working(→pending),`/cancel` 对 proposed/queued/pending;`/list` 显示全局板(含最近 finished),按 pending → working → queued → proposed → 最近 finished 排列,自己的条目标注「你的」,他人的标注「其他成员」(不暴露 ID)。pending card 每轮强提醒与兜底追加维持 owner-only。QQ 入站图片附件(image/*,单张 ≤5MB、每条最多 3 张、10 秒下载超时)下载为 base64 经 `IncomingMessage.attachments` 透传;agent 声明 `promptCapabilities.image` 时作为 ACP image content block 发给 Assistant/Worker,否则降级为文本说明;视频/文件附件不下载,仅以 `[视频] <url>` / `[文件] <url>` 文本拼接。
|
命令 `/help`、`/status`、`/list`、`/confirm`、`/finish`、`/stop`、`/cancel` 旁路 Assistant:`/confirm` 仍只对 owner 的 proposed 生效;`/finish` 对任意可见 pending 生效;`/stop` 对任意可见 working 生效(→pending);`/cancel` 对任意可见 proposed/queued/pending 生效;`/list` 显示全局板(含最近 finished),按 pending → working → queued → proposed → 最近 finished 排列,自己的条目标注「你的」,他人的标注「其他成员」(不暴露 ID)。pending card 每轮强提醒与兜底追加维持 owner-only。QQ 入站图片附件(image/*,单张 ≤5MB、每条最多 3 张、10 秒下载超时)下载为 base64 经 `IncomingMessage.attachments` 透传;agent 声明 `promptCapabilities.image` 时作为 ACP image content block 发给 Assistant/Worker,否则降级为文本说明;视频/文件附件不下载,仅以 `[视频] <url>` / `[文件] <url>` 文本拼接。
|
||||||
|
|
||||||
出站图片(QQ):Worker 在 `GORI_WORKER_RESULT_V2` 的 `attachments`(最多 3 个 `{path, mimeType?}`)上报 workspace 内 png/jpg(建议 `.gori-outbox/`),Assistant 也可用 `send_image { path }` 主动发图;runtime 校验 resolved realpath 必须在 canonical workspace 内、png/jpg magic bytes、单张 ≤10MB,违规丢弃并在事件文本说明。发送走 `POST /v2/{groups|users}/{id}/files` 上传(`file_type: 1` + base64 `file_data` + `srv_send_msg: false`)后 `msg_type: 7` + `media.file_info` 发送;群/私聊上传的 file_info 不通用,按目标分别上传。图片与文本共用 Gateway 的 `msg_id` + 递增 `msg_seq` 计数器;落定事件先文案后图;被动窗口过期时照旧记录并下次入站补发,补发按路径重读文件、文件缺失降级为文本说明;上传/发送失败不阻断文本,降级文本说明 + 安全日志。其他平台 adapter 无 `supportsImages` 标记时图片降级为 `[图片] <文件名>` 文本。
|
出站图片(QQ):Worker 在 `GORI_WORKER_RESULT_V2` 的 `attachments`(最多 3 个 `{path, mimeType?}`)上报 workspace 内 png/jpg(建议 `.gori-outbox/`),Assistant 也可用 `send_image { path }` 主动发图;runtime 校验 resolved realpath 必须在 canonical workspace 内、png/jpg magic bytes、单张 ≤10MB,违规丢弃并在事件文本说明。发送走 `POST /v2/{groups|users}/{id}/files` 上传(`file_type: 1` + base64 `file_data` + `srv_send_msg: false`)后 `msg_type: 7` + `media.file_info` 发送;群/私聊上传的 file_info 不通用,按目标分别上传。图片与文本共用 Gateway 的 `msg_id` + 递增 `msg_seq` 计数器;落定事件先文案后图;被动窗口过期时照旧记录并下次入站补发,补发按路径重读文件、文件缺失降级为文本说明;上传/发送失败不阻断文本,降级文本说明 + 安全日志。其他平台 adapter 无 `supportsImages` 标记时图片降级为 `[图片] <文件名>` 文本。
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ QQ 出站图片:Worker/Assistant 报告的 workspace 内图片(png/jpg、≤
|
|||||||
运行链路是三层:
|
运行链路是三层:
|
||||||
|
|
||||||
- **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`、`send_image`、`start_next`、`cancel`、`stop`;格式错误只修复一次(envelope 缺闭合标签时先按花括号配平 salvage,失败才修复)。`send_image { path }` 把 Worker 报告过的 workspace 内图片发给用户,与 Worker 附件同样的路径/类型/大小校验。
|
- **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`、`send_image`、`start_next`、`cancel`、`stop`;格式错误只修复一次(envelope 缺闭合标签时先按花括号配平 salvage,失败才修复)。`send_image { path }` 把 Worker 报告过的 workspace 内图片发给用户,与 Worker 附件同样的路径/类型/大小校验。
|
||||||
- **Proposal**:一份工作单,owner 是 chat + 发起用户;confirm/adjust/follow_up/finish/stop/cancel 全部只有发起人本人可操作(runtime 强校验),`start_next` 也只启动发起人自己的 queued Proposal;但 Proposal 板全局共享可见:Assistant prompt 的全板列表包含所有 chat/user 的未完成条目(自己的标 `scope=own`,他人的标 `scope=other` 并附 chat 类型,不暴露 openid 明文),Assistant 可如实向任何用户描述全板状态。状态流为 `proposed → queued → working → pending → finished`。`proposed` 只有用户确认后才进入 `queued`;`pending` 就是「等用户决定」,不再区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`。
|
- **Proposal**:一份工作单,owner 是 chat + 发起用户;`confirm` / `adjust` / `follow_up` / `start_next` 仍只有发起人本人可操作(runtime 强校验),但为了避免单人把单 worker 队列卡死,`finish` / `stop` / `cancel` 是全板共享动作:任何用户都可对可见 proposal 执行它们。Proposal 板全局共享可见:Assistant prompt 的全板列表包含所有 chat/user 的未完成条目(自己的标 `scope=own`,他人的标 `scope=other` 并附 chat 类型,不暴露 openid 明文),Assistant 可如实向任何用户描述全板状态。状态流为 `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`),不区分成功/失败,只把结果交给用户。Worker 给用户看的图片(png/jpg)必须保存在 workspace 内(建议 `.gori-outbox/`),并通过 `attachments: [{ path, mimeType? }]`(最多 3 个)上报;runtime 校验路径必须在 workspace 内、magic bytes 为 png/jpg、单张 ≤10MB,违规的丢弃并在事件文本里说明。
|
- **Worker**:同一时刻全实例只有一个,在 `bot.workspace` 用 `bot.permissions` policy 执行一个已确认 Proposal。每轮必须以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:`PENDING`(`summary` 必填,可带 `question`、`workspaceDirty`),不区分成功/失败,只把结果交给用户。Worker 给用户看的图片(png/jpg)必须保存在 workspace 内(建议 `.gori-outbox/`),并通过 `attachments: [{ path, mimeType? }]`(最多 3 个)上报;runtime 校验路径必须在 workspace 内、magic bytes 为 png/jpg、单张 ≤10MB,违规的丢弃并在事件文本里说明。
|
||||||
|
|
||||||
确认语义是刻意的:
|
确认语义是刻意的:
|
||||||
|
|||||||
@@ -153,18 +153,21 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listProposals(_platform: string, _chatId: string, _userId: string): Proposal[] {
|
listProposals(_platform: string, _chatId: string, _userId: string): Proposal[] {
|
||||||
// The board is globally visible; operation checks stay owner-only in the *Locked paths.
|
// The board is globally visible. confirm/adjust/follow_up/start_next stay owner-only;
|
||||||
|
// finish/cancel/stop are shared queue-management actions handled in the *Locked paths.
|
||||||
return this.proposals.list();
|
return this.proposals.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
async reset(platform: string, chatId: string, userId: string): Promise<void> {
|
async reset(platform: string, chatId: string, userId: string): Promise<boolean> {
|
||||||
const conversationKey = assistantKeyFor(chatKeyFor(platform, chatId), userId);
|
const conversationKey = assistantKeyFor(chatKeyFor(platform, chatId), userId);
|
||||||
const worker = this.assistantWorkers.get(conversationKey);
|
const worker = this.assistantWorkers.get(conversationKey);
|
||||||
if (worker) {
|
if (worker) {
|
||||||
this.assistantWorkers.delete(conversationKey);
|
this.assistantWorkers.delete(conversationKey);
|
||||||
await worker.terminate();
|
await worker.terminate();
|
||||||
}
|
}
|
||||||
await this.store.deleteBinding(conversationKey);
|
const binding = await this.store.deleteBinding(conversationKey);
|
||||||
|
if (worker || binding) console.log(`Assistant session reset by user for conversation ${conversationTag(conversationKey)}`);
|
||||||
|
return Boolean(worker || binding);
|
||||||
}
|
}
|
||||||
|
|
||||||
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean> {
|
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean> {
|
||||||
@@ -380,7 +383,7 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async finishLocked(chatKey: string, userId: string, id?: string, note?: string): Promise<boolean> {
|
private async finishLocked(chatKey: string, userId: string, id?: string, note?: string): Promise<boolean> {
|
||||||
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["pending"]);
|
const proposal = this.resolveSharedTargetProposal(id, ["pending"]);
|
||||||
if (!proposal) return false;
|
if (!proposal) return false;
|
||||||
await this.terminateActiveLocked(proposal.id);
|
await this.terminateActiveLocked(proposal.id);
|
||||||
await this.proposals.update(proposal.id, {
|
await this.proposals.update(proposal.id, {
|
||||||
@@ -394,7 +397,7 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async cancelLocked(chatKey: string, userId: string, id?: string): Promise<boolean> {
|
private async cancelLocked(chatKey: string, userId: string, id?: string): Promise<boolean> {
|
||||||
const proposal = this.resolveTargetProposal(chatKey, userId, id, ["proposed", "queued", "pending"]);
|
const proposal = this.resolveSharedTargetProposal(id, ["proposed", "queued", "pending"]);
|
||||||
if (!proposal) return false;
|
if (!proposal) return false;
|
||||||
await this.terminateActiveLocked(proposal.id);
|
await this.terminateActiveLocked(proposal.id);
|
||||||
await this.proposals.update(proposal.id, {
|
await this.proposals.update(proposal.id, {
|
||||||
@@ -409,7 +412,7 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
private async stopActiveLocked(chatKey: string, userId: string): Promise<boolean> {
|
private async stopActiveLocked(chatKey: string, userId: string): Promise<boolean> {
|
||||||
const active = this.active;
|
const active = this.active;
|
||||||
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
|
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
|
||||||
if (!active || !activeProposal || activeProposal.ownerChatKey !== chatKey || activeProposal.requesterUserId !== userId) return false;
|
if (!active || !activeProposal) return false;
|
||||||
active.settled = true;
|
active.settled = true;
|
||||||
this.active = undefined;
|
this.active = undefined;
|
||||||
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
|
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
|
||||||
@@ -462,6 +465,16 @@ export class AssistantManager implements ConversationRuntime {
|
|||||||
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
|
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveSharedTargetProposal(id: string | undefined, statuses: Proposal["status"][]): Proposal | undefined {
|
||||||
|
if (id) {
|
||||||
|
const proposal = this.proposals.get(id);
|
||||||
|
return proposal && statuses.includes(proposal.status) ? proposal : undefined;
|
||||||
|
}
|
||||||
|
return this.proposals.list()
|
||||||
|
.filter((proposal) => statuses.includes(proposal.status))
|
||||||
|
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
|
||||||
|
}
|
||||||
|
|
||||||
private launchWorker(proposalId: string, options: WorkerLaunchOptions = {}): void {
|
private launchWorker(proposalId: string, options: WorkerLaunchOptions = {}): void {
|
||||||
const proposal = this.proposals.get(proposalId);
|
const proposal = this.proposals.get(proposalId);
|
||||||
if (!proposal || proposal.status !== "working") return;
|
if (!proposal || proposal.status !== "working") return;
|
||||||
@@ -898,6 +911,10 @@ function proposalTag(proposalId: string): string {
|
|||||||
return proposalId.slice(0, 8);
|
return proposalId.slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function conversationTag(conversationKey: string): string {
|
||||||
|
return crypto.createHash("sha256").update(conversationKey).digest("hex").slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
// chatKey is `${platform}:${chatId}`; only the chat type is ever shown, never the raw ID.
|
// chatKey is `${platform}:${chatId}`; only the chat type is ever shown, never the raw ID.
|
||||||
function chatTypeOf(chatKey: string): string {
|
function chatTypeOf(chatKey: string): string {
|
||||||
const chatId = chatKey.slice(chatKey.indexOf(":") + 1);
|
const chatId = chatKey.slice(chatKey.indexOf(":") + 1);
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ export type RuntimeEventSink = (chatKey: string, text: string, images?: Outgoing
|
|||||||
export interface ConversationRuntime {
|
export interface ConversationRuntime {
|
||||||
prompt(request: ConversationRequest): Promise<ConversationResponse>;
|
prompt(request: ConversationRequest): Promise<ConversationResponse>;
|
||||||
cancel(platform: string, chatId: string, userId: string): Promise<boolean>;
|
cancel(platform: string, chatId: string, userId: string): Promise<boolean>;
|
||||||
reset(platform: string, chatId: string, userId: string): Promise<void>;
|
reset(platform: string, chatId: string, userId: string): Promise<boolean>;
|
||||||
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean>;
|
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean>;
|
||||||
stats(): RuntimeStats;
|
stats(): RuntimeStats;
|
||||||
shutdown(): Promise<void>;
|
shutdown(): Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type CommandKind = "help" | "status" | "list" | "confirm" | "finish" | "stop" | "cancel";
|
export type CommandKind = "help" | "status" | "list" | "confirm" | "finish" | "stop" | "cancel" | "reset";
|
||||||
|
|
||||||
export interface ParsedCommand {
|
export interface ParsedCommand {
|
||||||
kind: CommandKind;
|
kind: CommandKind;
|
||||||
@@ -17,6 +17,7 @@ export class CommandRouter {
|
|||||||
case "/finish": return { kind: "finish" };
|
case "/finish": return { kind: "finish" };
|
||||||
case "/stop": return { kind: "stop" };
|
case "/stop": return { kind: "stop" };
|
||||||
case "/cancel": return { kind: "cancel" };
|
case "/cancel": return { kind: "cancel" };
|
||||||
|
case "/reset": return { kind: "reset" };
|
||||||
default: return undefined;
|
default: return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-5
@@ -347,6 +347,19 @@ export class Gateway {
|
|||||||
}
|
}
|
||||||
case "cancel":
|
case "cancel":
|
||||||
return await this.runtime.cancel(message.platform, message.chatId, message.userId) ? "已取消最近的提案。" : "没有可取消的提案。";
|
return await this.runtime.cancel(message.platform, message.chatId, message.userId) ? "已取消最近的提案。" : "没有可取消的提案。";
|
||||||
|
case "reset": {
|
||||||
|
const had = await this.runtime.reset(message.platform, message.chatId, message.userId);
|
||||||
|
if (!had) return "当前没有需要重置的会话。";
|
||||||
|
const lines = ["已重置当前对话,下条消息开始一个全新的会话。"];
|
||||||
|
if (this.runtime.listProposals) {
|
||||||
|
const chatKey = chatKeyFor(message.platform, message.chatId);
|
||||||
|
const unfinished = this.runtime.listProposals(message.platform, message.chatId, message.userId)
|
||||||
|
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === message.userId)
|
||||||
|
.filter((proposal) => proposal.status !== "finished").length;
|
||||||
|
if (unfinished > 0) lines.push(`注意:你还有 ${unfinished} 个未完成任务,proposal 板不受影响。`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +385,7 @@ function renderProposalPanel(proposals: Proposal[], owned: (proposal: Proposal)
|
|||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
const sections: string[] = [];
|
const sections: string[] = [];
|
||||||
if (pending.length > 0) {
|
if (pending.length > 0) {
|
||||||
sections.push("待确认(等你处理):");
|
sections.push("待确认:");
|
||||||
for (const proposal of pending) {
|
for (const proposal of pending) {
|
||||||
const card = proposal.pending;
|
const card = proposal.pending;
|
||||||
sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}${card ? `:${card.summary}${card.question ? `(问你:${card.question})` : ""}` : ""}(说 /finish 结束,或直接说要求继续改)`);
|
sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}${card ? `:${card.summary}${card.question ? `(问你:${card.question})` : ""}` : ""}(说 /finish 结束,或直接说要求继续改)`);
|
||||||
@@ -413,9 +426,10 @@ const HELP_TEXT = [
|
|||||||
"直接用自然语言告诉我你要做什么即可,我会自己判断并处理。",
|
"直接用自然语言告诉我你要做什么即可,我会自己判断并处理。",
|
||||||
"兜底命令:",
|
"兜底命令:",
|
||||||
"/list 列出当前提案",
|
"/list 列出当前提案",
|
||||||
"/confirm 确认最近待确认的提案",
|
"/confirm 确认你最近待确认的提案",
|
||||||
"/finish 结束最近待确认的任务",
|
"/finish 结束最近待确认的任务(任何人可执行)",
|
||||||
"/stop 停止正在执行的任务",
|
"/stop 停止正在执行的任务(任何人可执行)",
|
||||||
"/cancel 取消最近未开始或待确认的提案",
|
"/cancel 取消最近未开始或待确认的提案(任何人可执行)",
|
||||||
|
"/reset 重置当前对话会话",
|
||||||
"/status 查看运行状态"
|
"/status 查看运行状态"
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|||||||
import type { AppConfig, BotConfig } from "../config.js";
|
import type { AppConfig, BotConfig } from "../config.js";
|
||||||
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
|
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
|
||||||
|
|
||||||
const BOOTSTRAP_SCHEMA_VERSION = 10;
|
const BOOTSTRAP_SCHEMA_VERSION = 11;
|
||||||
|
|
||||||
export interface ResolvedBot extends BotConfig {
|
export interface ResolvedBot extends BotConfig {
|
||||||
loadedSkills: LoadedSkill[];
|
loadedSkills: LoadedSkill[];
|
||||||
@@ -51,8 +51,8 @@ function buildAssistantBootstrap(bot: BotConfig): string {
|
|||||||
"When a worker's summary says it saved image files inside the workspace (for example under .gori-outbox/) and the user asks to see one, emit \"send_image\" with that exact path. Only send paths a worker actually reported; never invent paths.",
|
"When a worker's summary says it saved image files inside the workspace (for example under .gori-outbox/) and the user asks to see one, emit \"send_image\" with that exact path. Only send paths a worker actually reported; never invent paths.",
|
||||||
"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.",
|
"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.",
|
"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.",
|
||||||
"The [Proposal states] board is shared globally: you see every unfinished proposal. Entries marked scope=own belong to the current user; scope=other entries belong to someone else. You may honestly describe the whole board to anyone (what the bot is working on, how many tasks are queued), but actions only ever apply to scope=own entries: never claim you confirmed, adjusted, finished, stopped, or cancelled another user's task.",
|
"The [Proposal states] board is shared globally: you see every unfinished proposal. Entries marked scope=own belong to the current user; scope=other entries belong to someone else. You may honestly describe the whole board to anyone (what the bot is working on, how many tasks are queued). Confirm, adjust_proposal, follow_up, and start_next only apply to scope=own entries. finish, cancel, and stop may target any visible entry when the user explicitly asks you to close, cancel, or stop it.",
|
||||||
"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.",
|
"In a group chat each proposal is still owned by the user who requested it for confirm, adjust, follow_up, and start_next. finish, stop, and cancel are shared actions: any user may use them on visible proposals to unblock the single worker and the shared queue.",
|
||||||
"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."
|
"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");
|
].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,18 +392,19 @@ test("proposals are owned per chat+user: a second user in the same chat cannot c
|
|||||||
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-b"), false);
|
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-b"), false);
|
||||||
await harness.manager.prompt(request("confirm", "user-b"));
|
await harness.manager.prompt(request("confirm", "user-b"));
|
||||||
assert.equal(harness.proposals.get(proposal.id)!.status, "proposed");
|
assert.equal(harness.proposals.get(proposal.id)!.status, "proposed");
|
||||||
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-b"), false);
|
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-b"), true);
|
||||||
await harness.manager.prompt(request("cancel proposal", "user-b"));
|
assert.equal(harness.proposals.get(proposal.id)!.status, "finished");
|
||||||
assert.equal(harness.proposals.get(proposal.id)!.status, "proposed");
|
assert.equal(harness.proposals.get(proposal.id)!.finishKind, "cancelled");
|
||||||
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-b"), false);
|
|
||||||
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
await harness.manager.prompt(request("create proposal: hang", "user-a"));
|
||||||
|
const running = harness.proposals.list().find((item) => item.status === "proposed")!;
|
||||||
|
|
||||||
// The board is globally visible: both users see the same proposal, tagged by scope.
|
// The board is globally visible: both users see the same proposal, tagged by scope.
|
||||||
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-b").length, 1);
|
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-b").some((item) => item.id === running.id), true);
|
||||||
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-a").length, 1);
|
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-a").some((item) => item.id === running.id), true);
|
||||||
|
|
||||||
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-a"), true);
|
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-a"), true);
|
||||||
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
|
assert.equal(harness.proposals.get(running.id)!.status, "working");
|
||||||
|
|
||||||
await harness.manager.prompt(request("hello owner", "user-a"));
|
await harness.manager.prompt(request("hello owner", "user-a"));
|
||||||
const aPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello owner"));
|
const aPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello owner"));
|
||||||
@@ -420,11 +421,11 @@ test("proposals are owned per chat+user: a second user in the same chat cannot c
|
|||||||
assert.match(bPrompt.text!, /scope=other chat=direct/);
|
assert.match(bPrompt.text!, /scope=other chat=direct/);
|
||||||
assert.doesNotMatch(bPrompt.text!, /user-a/);
|
assert.doesNotMatch(bPrompt.text!, /user-a/);
|
||||||
|
|
||||||
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), true);
|
||||||
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
|
assert.equal(harness.proposals.get(running.id)!.status, "pending");
|
||||||
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
|
assert.equal(harness.proposals.get(running.id)!.pending?.summary, "被用户中止");
|
||||||
assert.equal(harness.proposals.get(proposal.id)!.status, "pending");
|
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-b"), true);
|
||||||
assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "被用户中止");
|
assert.equal(harness.proposals.get(running.id)!.status, "finished");
|
||||||
} finally {
|
} finally {
|
||||||
await closeHarness(harness);
|
await closeHarness(harness);
|
||||||
}
|
}
|
||||||
@@ -662,32 +663,44 @@ test("a working proposal blocks start_next for everyone with a sanitized reason"
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("confirm, finish, stop, cancel, and follow_up actions with no matching proposal append an ownership correction", async () => {
|
test("confirm and follow_up stay owner-only while finish, stop, and cancel are shared", async () => {
|
||||||
const harness = await createHarness();
|
const harness = await createHarness();
|
||||||
try {
|
try {
|
||||||
await harness.manager.prompt(request("create proposal: hang", "user-a"));
|
await harness.manager.prompt(request("create proposal: hang", "user-a"));
|
||||||
|
const proposed = harness.proposals.list()[0]!;
|
||||||
|
|
||||||
const confirmed = await harness.manager.prompt(request("confirm", "user-b"));
|
const confirmed = await harness.manager.prompt(request("confirm", "user-b"));
|
||||||
assert.match(confirmed.text, /没有可确认的提案/);
|
assert.match(confirmed.text, /没有可确认的提案/);
|
||||||
assert.match(confirmed.text, /自己发起/);
|
assert.match(confirmed.text, /自己发起/);
|
||||||
|
|
||||||
|
const cancelled = await harness.manager.prompt(request("cancel proposal", "user-b"));
|
||||||
|
assert.equal(cancelled.text, "cancelling");
|
||||||
|
assert.equal(harness.proposals.get(proposed.id)!.status, "finished");
|
||||||
|
assert.equal(harness.proposals.get(proposed.id)!.finishKind, "cancelled");
|
||||||
|
|
||||||
|
await harness.manager.prompt(request("create proposal: ask", "user-a"));
|
||||||
|
const pending = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
|
||||||
|
await harness.manager.prompt(request("confirm", "user-a"));
|
||||||
|
await waitFor(() => harness.proposals.get(pending.id)!.status === "pending");
|
||||||
|
|
||||||
const finished = await harness.manager.prompt(request("finish", "user-b"));
|
const finished = await harness.manager.prompt(request("finish", "user-b"));
|
||||||
assert.match(finished.text, /没有可结束的任务/);
|
assert.equal(finished.text, "finishing");
|
||||||
assert.match(finished.text, /自己发起/);
|
assert.equal(harness.proposals.get(pending.id)!.status, "finished");
|
||||||
|
assert.equal(harness.proposals.get(pending.id)!.finishKind, "done");
|
||||||
|
|
||||||
|
await harness.manager.prompt(request("create proposal: hang", "user-a"));
|
||||||
|
const running = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
|
||||||
|
await harness.manager.prompt(request("confirm", "user-a"));
|
||||||
|
assert.equal(harness.proposals.get(running.id)!.status, "working");
|
||||||
|
|
||||||
const stopped = await harness.manager.prompt(request("stop", "user-b"));
|
const stopped = await harness.manager.prompt(request("stop", "user-b"));
|
||||||
assert.match(stopped.text, /没有可停止的任务/);
|
assert.equal(stopped.text, "stopping");
|
||||||
assert.match(stopped.text, /自己发起/);
|
assert.equal(harness.proposals.get(running.id)!.status, "pending");
|
||||||
|
assert.equal(harness.proposals.get(running.id)!.pending?.summary, "被用户中止");
|
||||||
const cancelled = await harness.manager.prompt(request("cancel proposal", "user-b"));
|
|
||||||
assert.match(cancelled.text, /没有可取消的提案/);
|
|
||||||
assert.match(cancelled.text, /自己发起/);
|
|
||||||
|
|
||||||
const followedUp = await harness.manager.prompt(request("follow up: more", "user-b"));
|
const followedUp = await harness.manager.prompt(request("follow up: more", "user-b"));
|
||||||
assert.match(followedUp.text, /现在没法继续/);
|
assert.match(followedUp.text, /现在没法继续/);
|
||||||
assert.match(followedUp.text, /自己发起/);
|
assert.match(followedUp.text, /自己发起/);
|
||||||
|
|
||||||
assert.equal(harness.proposals.list()[0]!.status, "proposed");
|
|
||||||
} finally {
|
} finally {
|
||||||
await closeHarness(harness);
|
await closeHarness(harness);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-2
@@ -50,6 +50,7 @@ class FakeRuntime implements ConversationRuntime {
|
|||||||
confirmResult = true;
|
confirmResult = true;
|
||||||
finishResult = true;
|
finishResult = true;
|
||||||
stopResult = true;
|
stopResult = true;
|
||||||
|
resetResult = false;
|
||||||
proposals: Proposal[] = [];
|
proposals: Proposal[] = [];
|
||||||
commandUsers: Record<string, string | undefined> = {};
|
commandUsers: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
@@ -66,8 +67,8 @@ class FakeRuntime implements ConversationRuntime {
|
|||||||
async confirm(_platform: string, _chatId: string, userId: string) { this.confirmed++; this.commandUsers.confirm = userId; return this.confirmResult; }
|
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 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; }
|
async stop(_platform: string, _chatId: string, userId: string) { this.stopped++; this.commandUsers.stop = userId; return this.stopResult; }
|
||||||
|
async reset(_platform: string, _chatId: string, userId: string) { this.commandUsers.reset = userId; return this.resetResult; }
|
||||||
listProposals(_platform: string, _chatId: string, userId: string) { this.listed++; this.commandUsers.list = userId; return this.proposals; }
|
listProposals(_platform: string, _chatId: string, userId: string) { this.listed++; this.commandUsers.list = userId; return this.proposals; }
|
||||||
async reset() {}
|
|
||||||
status(_platform?: string, _chatId?: string, userId?: string) {
|
status(_platform?: string, _chatId?: string, userId?: string) {
|
||||||
this.commandUsers.status = userId;
|
this.commandUsers.status = userId;
|
||||||
return {
|
return {
|
||||||
@@ -590,6 +591,7 @@ test("status and help reach the runtime or reply directly", async () => {
|
|||||||
assert.match(help.reply || "", /\/finish/);
|
assert.match(help.reply || "", /\/finish/);
|
||||||
assert.match(help.reply || "", /\/stop/);
|
assert.match(help.reply || "", /\/stop/);
|
||||||
assert.match(help.reply || "", /\/cancel/);
|
assert.match(help.reply || "", /\/cancel/);
|
||||||
|
assert.match(help.reply || "", /\/reset/);
|
||||||
runtime.turns[0].resolve("done");
|
runtime.turns[0].resolve("done");
|
||||||
await turn;
|
await turn;
|
||||||
});
|
});
|
||||||
@@ -654,7 +656,7 @@ test("the list panel orders pending first, then working, queued, proposed, and r
|
|||||||
|
|
||||||
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
|
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
|
||||||
const reply = list.reply || "";
|
const reply = list.reply || "";
|
||||||
const order = ["待确认(等你处理)", "进行中", "排队中", "未确认(proposed)", "最近结束"]
|
const order = ["待确认", "进行中", "排队中", "未确认(proposed)", "最近结束"]
|
||||||
.map((section) => reply.indexOf(section));
|
.map((section) => reply.indexOf(section));
|
||||||
assert.ok(order.every((index) => index >= 0), reply);
|
assert.ok(order.every((index) => index >= 0), reply);
|
||||||
assert.deepEqual([...order].sort((a, b) => a - b), order);
|
assert.deepEqual([...order].sort((a, b) => a - b), order);
|
||||||
@@ -665,6 +667,25 @@ test("the list panel orders pending first, then working, queued, proposed, and r
|
|||||||
assert.match(reply, /p-finished「旧任务」(你的)已完成/);
|
assert.match(reply, /p-finished「旧任务」(你的)已完成/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("/reset resets the current conversation session and reminds about unfinished proposals", async () => {
|
||||||
|
const { runtime, gateway } = createGateway();
|
||||||
|
const adapter = recordingAdapter();
|
||||||
|
|
||||||
|
const noSession = await gateway.receive(message("/reset"), adapter, { synchronous: true });
|
||||||
|
assert.equal(noSession.reply, "当前没有需要重置的会话。");
|
||||||
|
|
||||||
|
runtime.resetResult = true;
|
||||||
|
const reset = await gateway.receive(message("/reset"), adapter, { synchronous: true });
|
||||||
|
assert.match(reset.reply || "", /已重置当前对话/);
|
||||||
|
assert.doesNotMatch(reset.reply || "", /未完成任务/);
|
||||||
|
assert.equal(runtime.commandUsers.reset, "user");
|
||||||
|
|
||||||
|
runtime.proposals = [fakeProposal({ id: "p-1", status: "pending", pending: { summary: "s", receivedAt: 1 } })];
|
||||||
|
const reminded = await gateway.receive(message("/reset"), adapter, { synchronous: true });
|
||||||
|
assert.match(reminded.reply || "", /已重置当前对话/);
|
||||||
|
assert.match(reminded.reply || "", /你还有 1 个未完成任务,proposal 板不受影响/);
|
||||||
|
});
|
||||||
|
|
||||||
test("the list panel shows other members' proposals without exposing their identity", async () => {
|
test("the list panel shows other members' proposals without exposing their identity", async () => {
|
||||||
const { runtime, gateway } = createGateway();
|
const { runtime, gateway } = createGateway();
|
||||||
const adapter = recordingAdapter();
|
const adapter = recordingAdapter();
|
||||||
|
|||||||
Reference in New Issue
Block a user