diff --git a/docs/architecture/automation-platform-architecture.md b/docs/architecture/automation-platform-architecture.md index c2c44581..4bffee87 100644 --- a/docs/architecture/automation-platform-architecture.md +++ b/docs/architecture/automation-platform-architecture.md @@ -293,11 +293,14 @@ Runtime 边界完成,用户可见结果通过所属 Task 汇入关联 Conversa - 普通消息在发送时冻结 Runtime、工作模式、专家/团队、知识范围和附件上下文,再以 `source=user` 入队;附件内容使用有界序列化保存,应用重启后仍可恢复。 - 到期或手动启动的 Scheduled Task 先建立 `schedule_run`,再以 `source=schedule` 进入同一 - 队列。Scheduler 不再绕过队列直接调用 Runtime。 + 队列,只派发任务要求和关联会话身份。执行时读取会话最新历史和配置,不保存任务上下文快照。 - Main 对每条 Conversation 只保留一个活动请求或 Renderer 派发保留位。默认按 FIFO 认领; - 全局 Scheduled Task 执行仍受最多 4 项并发限制。 -- 用户消息由 Main 派发给 Renderer,由 Renderer 建立用户消息和流式助手消息后调用 - `agent.run`;Scheduled Task 由 Main 直接执行。两条路径共享同一 Conversation 活动锁。 + Scheduled Task 不另设执行器或并发计数。 +- 两种来源均由 Main 派发给 Renderer,建立普通用户消息和流式助手消息后调用 `agent.run`。 + Renderer 加载会话及设置后才声明队列可派发。SSH 项目同样通过该入口选择远程 Runtime, + 使用普通请求的上下文、工具范围、取消和恢复流程。 +- `schedule_run.id` 作为普通请求 ID;普通请求终态同步该次计划运行与稳定产品 Task 的状态。 + 尚未接受的派发可释放重试;已经接受的运行在重启后按普通 Task 状态结算或恢复,不重新发送。 - 当前执行到达终态后再认领下一项。删除只移除尚未执行的项;“立即中断并插入”取消当前 请求并把所选项设为下一项,不重排其他项。 - 每条 Conversation 最多保留 20 个用户可提交的待执行项。启动时将未完成的派发恢复为 diff --git a/docs/features/task-and-job/scheduled-task-prd.md b/docs/features/task-and-job/scheduled-task-prd.md index 3151d688..4706d4c0 100644 --- a/docs/features/task-and-job/scheduled-task-prd.md +++ b/docs/features/task-and-job/scheduled-task-prd.md @@ -5,8 +5,8 @@ | 项目 | 内容 | | --- | --- | | 状态 | 首期稳定 Task 生命周期、创建体验与 Conversation 输入仲裁已实现;高级触发和执行治理待实施 | -| 版本 | 0.7 | -| 日期 | 2026-08-19 | +| 版本 | 0.8 | +| 日期 | 2026-09-10 | | 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) | | 相关架构 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md) | @@ -15,6 +15,10 @@ Scheduled Task 是带时间或事件触发器的 Task。每个 Scheduled Task 关联一条 Conversation; 一条 Conversation 可以同时承载多个 Task。 +当前时间触发的含义是向关联的普通 Conversation 发送一条任务要求。执行时读取该会话的 +最新历史、Runtime、工作模式和知识范围,工具配置使用普通发送的同一规则。任务不保存 +创建时的上下文或权限快照。新建会话按普通会话规则初始化,不复制来源会话的历史和配置。 + 创建 Scheduled Task 时: 1. 用户选择关联当前 Conversation 或创建新 Conversation。 @@ -34,20 +38,20 @@ GoodBuddy 当前已实现首期统一生命周期: Conversation 的标题和既有消息。 - 每个 Schedule 绑定一个稳定产品级 Task 和 Conversation;重复触发复用同一身份,不再 为每次触发创建新的顶层 Task。 -- 默认选择 Execute,并允许用户主动切换 Ask;不支持工具执行时明确禁用 Execute。 -- 单次、每日和每周计划支持暂停、恢复、立即运行、应用重启恢复和最多 4 个独立计划并发。 +- 创建表单只设置任务要求、关联会话和时间;工作模式在关联会话中设置,未设置时使用项目默认值。 +- 单次、每日和每周计划支持暂停、恢复、立即运行和应用重启恢复。 - 到期和手动运行先进入关联 Conversation 的持久输入队列,与回复期间继续发送的普通消息 顺序仲裁;默认不打断当前回复,也不与其并发写入时间线。 - Composer 上沿显示待发送项和来源。用户可以删除尚未执行的 occurrence,或选择“立即 中断并插入”取消当前执行并将该项提升为下一项。 -- 文本结果和失败写回关联 Conversation 并带 Task 来源;独立文件和图片继续保存为 Artifact。 +- 任务要求成为普通用户消息,回复、工具活动、提问和失败通过普通聊天流程呈现;独立文件和图片继续保存为 Artifact。 - 左侧 Conversation 列表、Conversation Task 区和 Task Center 使用同一产品 Task;普通 模型请求、Subagent、委派和 Smart Heartbeat 内部 Task 不进入产品索引。 - v22 迁移保留 Schedule 配置和历史运行,并为旧计划补齐稳定 Task 与 Conversation;v23 增加可恢复的统一 Conversation 输入队列。 尚未实现的高级能力包括 IANA 时区与 DST 墙上时间、每月/工作日/受限 Cron、事件触发、 -可配置错过执行策略、租约、重试与结果未知治理、完整预算和权限快照,以及面向内部 +可配置错过执行策略、租约、重试与结果未知治理、完整预算,以及面向内部 Job/Subjob/Run 的统一持久化抽象。当前每日和每周按既有 UTC 间隔递推。 ## 3. 目标 @@ -55,8 +59,8 @@ Job/Subjob/Run 的统一持久化抽象。当前每日和每周按既有 UTC 间 - 支持单次、每日、每周、每月、工作日和受限 Cron。 - 支持 Task 完成、失败、Conversation 完成等内部事件触发。 - 创建时明确选择当前或新 Conversation。 -- 默认使用 Execute,并允许用户主动切换到 Ask。 -- 冻结 Project、Runtime、工作目录、工具、知识、记忆和审批范围。 +- 使用关联会话当前的工作模式;新会话使用普通项目默认值。 +- 在实际发送时解析上下文和执行配置,与手动发送共用执行流程。 - 提供时区、错过执行、幂等、租约、重试、恢复、取消、预算和审计。 - 让所有重复触发复用同一 Task 和 Conversation 关联。 - 为一次触发建立清晰 Job/Run,而不是创建新的顶层 Task。 @@ -93,18 +97,15 @@ Task Center 和 Conversation 操作都可以提供“新建定制任务”,但 ○ 新建会话 为任务创建一条新会话,默认标题为任务名称 -执行模式 -[ Execute ] [ Ask ] - 运行频率 [ 单次 ] [ 每日 ] [ 每周 ] [ 每月 ] [ 工作日 ] [ Cron ] 首次运行 [ 2026-08-21 ] [ 17:00 ] 时区 [ Asia/Shanghai ▾ ] -执行范围 -GoodBuddy Desktop · OpenCode · 项目工作目录 -8 个工具可用 · 高风险操作需要审批 [编辑] +关联项目 +GoodBuddy Desktop · 项目工作目录 +运行时使用关联会话的当前上下文和配置 [取消] [创建任务] ``` @@ -126,7 +127,7 @@ GoodBuddy Desktop · OpenCode · 项目工作目录 ```text ✓ 为当前 Conversation 新增一个 Task ✓ 在左侧会话列表显示“任务 3” -✓ 默认以 Execute 模式运行 +✓ 使用关联会话运行时的工作模式 ✓ 每周五 17:00 自动执行此 Task ✓ 文本结果写入当前 Conversation ✓ 独立交付物保存到成果 @@ -145,28 +146,19 @@ GoodBuddy Desktop · OpenCode · 项目工作目录 ## 6. 工作模式、Runtime 与工具 -### 6.1 默认 Execute +### 6.1 会话工作模式 -创建 Modal 默认选择 Execute: +创建 Modal 不单独设置工作模式。关联已有会话时,使用该会话实际发送时的工作模式; +新会话未单独设置时使用项目默认模式。Ask 在 Runtime 边界保持只读,Execute 使用普通 +会话的工具授权规则。 -- Execute 可以调用当前 Runtime 与 Project 已启用、且被 Task 快照允许的工具。 -- Ask 保持 Runtime 边界只读,只能调用允许的只读能力。 -- 所选 Runtime 不支持工具执行时,不能静默降级为 Ask;用户必须更换 Runtime 或主动选择 - Ask。 -- Modal 持续显示实际 Runtime、Project、工作目录和权限摘要。 +### 6.2 执行时配置 -### 6.2 权限快照 +计划只保存任务要求、时间和稳定会话关联。Runtime、工作目录、Skills、MCP、知识范围、 +记忆和工具配置由普通发送流程在运行时解析,不另存任务上下文快照。已有会话的后续消息 +参与下一次执行;新会话不继承来源会话历史。重复触发始终发送到同一关联会话。 -Task 创建时冻结: - -- Project 和工作目录。 -- Runtime 与模型选择。 -- 工作模式。 -- Skills、MCP、知识库、记忆和上下文范围。 -- 可用工具与审批策略。 -- 预算、并发和输出限制。 - -后续设置变化不修改已启动 Run。编辑 Task 配置只影响后续 Job。 +已保存计划中的旧工作模式和 Runtime 字段不再控制执行;用户在关联会话或项目中调整配置。 ### 6.3 审批 @@ -261,16 +253,16 @@ Conversation ```text ▾ 产品发布讨论 10:24 - 每周进度总结 每周 · Execute · 等待中 ● - 发布前检查 单次 · Execute ✓ + 每周进度总结 每周 · 等待中 ● + 发布前检查 单次 ✓ ``` -- 父会话行不重复显示任务标签或数量;Task 子项名称在左,模式、计划和状态标记收在右侧, +- 父会话行不重复显示任务标签或数量;Task 子项名称在左,计划和状态标记收在右侧, 与父会话的更新时间位置一致。 - 点击 Conversation 标题打开聊天;点击 Task 子项打开同一 Conversation 并定位到该 Task。 - 新建 Task 成功后首次自动展开。用户手动折叠后持久化其选择,后台运行不强制展开。 - 默认最多直接显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整 Task 区。 -- Task 子项显示本地化的模式、计划和非完成态状态文字;状态标记固定在行尾。已完成使用 +- Task 子项显示本地化的计划和非完成态状态文字;状态标记固定在行尾。已完成使用 带勾成功点,并将完成文案保留给辅助技术,避免在紧凑元数据中重复显示且不只靠颜色表达。 - 左侧只展开 Task;当前产品 UI 的其他区域也不提供 Job/Run 树或独立导航。 @@ -285,19 +277,19 @@ Conversation 选中 Task 后显示: -- 名称、状态和模式。 +- 名称和状态。 - 计划、下次执行和未来预览。 - 最近一次执行结果。 - “立即运行”“暂停”“编辑计划”等操作。 - 需要审批时的明确恢复入口。 -每条自动结果消息显示 Task 名称、触发来源和时间。普通文本作为消息保存;文件、图片、PDF 和 -其他独立交付物保存为 Artifact,并从消息引用。多个 Task 并发时,最终文本以完整消息写入, -不能把流式 Token 无序混入同一消息时间线。 +待发送项显示计划来源。发送后使用普通用户消息和流式助手消息;文件、图片、PDF 和其他 +独立交付物保存为 Artifact,并从消息引用。同一会话按队列串行执行,避免流式输出混入 +另一条回复。 ## 11. Task Center -Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模式、最近进展、需要 +Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、最近进展、需要 关注和下次触发时间: - 点击条目打开关联 Conversation,并定位到该 Task。 @@ -355,9 +347,9 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模 - [x] 创建 Scheduled Task 可以选择当前或新 Conversation。 - [x] 关联当前 Conversation 不修改其标题、类型或既有消息。 - [x] 一条 Conversation 可以在左侧展开一个或多个 Task。 -- [x] 默认工作模式为 Execute,且用户可以主动选择 Ask。 -- [ ] Execute 能调用快照允许的工具,但不能绕过 Runtime 和审批控制。 -- [x] 不支持工具的 Runtime 不会让 Execute 静默降级。 +- [x] 工作模式使用关联会话当前设置,未设置时使用项目默认值。 +- [x] 使用普通发送的 Runtime 和工具执行流程,不保存任务上下文快照。 +- [x] 新会话不复制来源会话历史;重复执行读取关联会话最新消息。 - [x] 重复触发始终复用同一 Task 和 Conversation 关联。 - [x] 每次触发创建内部运行记录,不创建新的顶层 Task。 - [x] 内部运行记录只用于执行和审计,不在 UI 中显示为独立层级。 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index faa6eeda..ffdc2d15 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -131,6 +131,80 @@ function claimManualScheduleQueueItem( } describe('AssistantDatabase', () => { + it('keeps timed messages independent of creation-time execution settings', async () => { + const database = await createDatabase() + const project = database.createProject({ + name: 'Scheduled project', rootPath: 'C:\\Workspace', description: '', + defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } + }) + const schedule = database.createSchedule({ + projectId: project.id, title: 'Timed message', prompt: 'Continue our work', + recurrence: 'daily', nextRunAt: '2026-09-10T00:00:00Z' + }) + expect(schedule.runtimeSelection).toBeUndefined() + const conversation = database.getConversation(schedule.conversationId) + expect(conversation.runtimeSelection).toBeUndefined() + expect(conversation.workMode).toBeUndefined() + const { messages, ...header } = conversation + database.saveLocalConversations([{ header: { + ...header, workMode: 'execute', + runtimeSelection: { provider: 'continue' } + }, messages }]) + const existing = database.createSchedule({ + projectId: project.id, conversationId: conversation.id, + title: 'Second message', prompt: 'Continue', workMode: 'ask', + recurrence: 'once', nextRunAt: '2026-09-11T00:00:00Z' + }) + expect(existing.runtimeSelection).toBeUndefined() + database.close() + database.initialize('C:\\Workspace') + expect(database.getConversation(conversation.id)).toMatchObject({ + workMode: 'execute', runtimeSelection: { provider: 'continue' } + }) + database.close() + }) + + it('releases scheduled dispatches for retry and completes accepted ordinary runs without replay on restart', async () => { + const database = await createDatabase() + const schedule = database.createSchedule({ + title: 'Timed message', prompt: 'Continue', recurrence: 'daily', + nextRunAt: '2026-09-10T00:00:00Z' + }) + const item = database.queueScheduleNow(schedule.id) + expect(database.claimConversationQueueItem(schedule.conversationId)?.source).toBe('schedule') + expect(database.isConversationUserQueueItemDispatching(item.id)).toBe(true) + database.releaseConversationUserQueueItem(item.id) + expect(database.claimConversationQueueItem(schedule.conversationId)?.source).toBe('schedule') + database.createTask({ id: item.scheduleRunId!, conversationId: schedule.conversationId, + title: 'Continue', instructions: 'Continue', workMode: 'execute', visible: false }) + database.completeConversationUserQueueItem(item.id) + database.updateTaskStatus(item.scheduleRunId!, 'completed') + database.completeTaskScheduleRun(item.scheduleRunId!) + database.completeTaskScheduleRun(item.scheduleRunId!) + database.close() + database.initialize('C:\\Workspace') + expect(database.listConversationQueueItems(schedule.conversationId)).toEqual([]) + expect(database.listSchedules().find(candidate => candidate.id === schedule.id)?.lastRunAt).toBeDefined() + database.close() + }) + + it('does not resend an accepted scheduled message interrupted by a local restart', async () => { + const database = await createDatabase() + const schedule = database.createSchedule({ + title: 'Timed message', prompt: 'Continue', recurrence: 'once', + nextRunAt: '2026-09-10T00:00:00Z' + }) + const item = database.queueScheduleNow(schedule.id) + database.claimConversationQueueItem(schedule.conversationId) + database.createTask({ id: item.scheduleRunId!, conversationId: schedule.conversationId, + title: 'Continue', instructions: 'Continue', workMode: 'execute', visible: false }) + database.completeConversationUserQueueItem(item.id) + database.close() + database.initialize('C:\\Workspace') + expect(database.listConversationQueueItems(schedule.conversationId)).toEqual([]) + expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('failed') + database.close() + }) it('rejects a newer unsupported schema without changing its version', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-future-') @@ -5221,7 +5295,7 @@ describe('AssistantDatabase', () => { }, continueModelSource: { kind: 'platform' } }) - ).toBe(4) + ).toBe(3) expect( database .listConversations() @@ -5235,7 +5309,8 @@ describe('AssistantDatabase', () => { { provider: 'model', profileId: runtimeProfileId } ]) expect(database.getProject(channelProject.id).runtimeSelection).toEqual({ - provider: 'opencode' + provider: 'opencode', + profileId: runtimeProfileId }) expect( database.getProject(imageChannelProject.id).runtimeSelection diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 286b3636..e05b69aa 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -1047,6 +1047,7 @@ function interruptActiveToolBlocks( } const conversationContextStateSchema = conversationSnapshotSchema.pick({ + workMode: true, knowledgeLibraryIds: true, contextMetrics: true, contextCompressionState: true @@ -1056,7 +1057,7 @@ function parseConversationContextState( value: string | null ): Pick< ConversationSnapshot, - 'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState' + 'workMode' | 'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState' > { if (!value) { return {} @@ -1074,13 +1075,14 @@ function parseConversationContextState( function serializeConversationContextState( conversation: Pick< ConversationSnapshot, - 'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState' + 'workMode' | 'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState' > ): string | null { - return conversation.knowledgeLibraryIds !== undefined || + return conversation.workMode !== undefined || conversation.knowledgeLibraryIds !== undefined || conversation.contextMetrics || conversation.contextCompressionState ? JSON.stringify({ + workMode: conversation.workMode, knowledgeLibraryIds: conversation.knowledgeLibraryIds, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState @@ -1847,7 +1849,8 @@ export class AssistantDatabase { .prepare( `UPDATE schedule_runs SET status = 'pending' - WHERE status = 'running'` + WHERE status = 'running' + AND NOT EXISTS (SELECT 1 FROM tasks WHERE tasks.id = schedule_runs.id)` ) .run() database @@ -1985,6 +1988,12 @@ export class AssistantDatabase { database.exec('ROLLBACK') throw error } + const startedSchedules = database.prepare( + `SELECT id FROM schedule_runs WHERE status = 'running'` + ).all() as Array<{ id: string }> + for (const run of startedSchedules) { + this.completeTaskScheduleRun(run.id) + } } catch (error) { database.close() throw error @@ -5795,7 +5804,7 @@ export class AssistantDatabase { .prepare( `SELECT 1 FROM conversation_queue_items - WHERE id = ? AND source = 'user' AND status = 'dispatching'` + WHERE id = ? AND status = 'dispatching'` ) .get(itemId) ) @@ -5953,7 +5962,7 @@ export class AssistantDatabase { const result = this.requireDatabase() .prepare( `DELETE FROM conversation_queue_items - WHERE id = ? AND source = 'user' AND status = 'dispatching'` + WHERE id = ? AND status = 'dispatching'` ) .run(itemId) if (result.changes !== 1) { @@ -5962,11 +5971,14 @@ export class AssistantDatabase { } releaseConversationUserQueueItem(itemId: string): void { - const result = this.requireDatabase() + const database = this.requireDatabase() + database.exec('BEGIN IMMEDIATE') + try { + const result = database .prepare( `UPDATE conversation_queue_items SET status = 'pending' - WHERE id = ? AND source = 'user' AND status = 'dispatching'` + WHERE id = ? AND status = 'dispatching'` ) .run(itemId) if ( @@ -5975,12 +5987,20 @@ export class AssistantDatabase { .prepare( `SELECT 1 FROM conversation_queue_items - WHERE id = ? AND source = 'user' AND status = 'pending'` + WHERE id = ? AND status = 'pending'` ) .get(itemId) ) { throw new Error('待发送消息不存在或状态已变化') } + database.prepare(`UPDATE schedule_runs SET status = 'pending' + WHERE id = (SELECT schedule_run_id FROM conversation_queue_items WHERE id = ?) + AND status = 'running'`).run(itemId) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } } removeConversationUserQueueItem(itemId: string): void { @@ -6054,7 +6074,6 @@ export class AssistantDatabase { try { let projectId = parsed.projectId ?? null let conversationId = parsed.conversationId - let runtimeSelectionJson: string | null = null if (conversationId) { const conversation = database .prepare( @@ -6083,7 +6102,6 @@ export class AssistantDatabase { throw new Error('任务项目与所选对话不一致') } projectId = conversation.project_id - runtimeSelectionJson = conversation.runtime_selection_json } else { if (projectId) { const project = database @@ -6098,7 +6116,6 @@ export class AssistantDatabase { if (!project) { throw new Error('任务项目不存在或不可用') } - runtimeSelectionJson = project.runtime_selection_json } conversationId = randomUUID() database @@ -6111,8 +6128,8 @@ export class AssistantDatabase { .run( conversationId, projectId, - runtimeSelectionJson, - parsed.workMode, + null, + 'ask', parsed.title, now, now @@ -6132,9 +6149,7 @@ export class AssistantDatabase { JSON.stringify({ title: parsed.title, prompt: parsed.prompt, - workMode: parsed.workMode, - runtimeSelection: - parseRuntimeSelection(runtimeSelectionJson) + workMode: parsed.workMode }), JSON.stringify({ type: parsed.recurrence }), parsed.nextRunAt, @@ -6425,6 +6440,19 @@ export class AssistantDatabase { return this.getConversationQueueItem(runId)! } + completeTaskScheduleRun(taskId: string): void { + const run = this.requireDatabase().prepare( + `SELECT t.status, t.remote_recoverable FROM schedule_runs sr + INNER JOIN tasks t ON t.id = sr.id + WHERE sr.id = ? AND sr.status = 'running'` + ).get(taskId) as { status: string; remote_recoverable: number } | undefined + if (run && !(run.status === 'interrupted' && run.remote_recoverable === 1) && + ['completed', 'failed', 'cancelled', 'interrupted'].includes(run.status)) { + this.completeScheduleRun(taskId, + run.status === 'completed' || run.status === 'cancelled' ? run.status : 'failed') + } + } + completeScheduleRun( runId: string, status: 'completed' | 'failed' | 'cancelled', diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 2804691d..81ed8fb6 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -5112,6 +5112,7 @@ describe('registerIpcHandlers agent terminal state', () => { queueDueSchedules: vi.fn(() => []), queueScheduleNow: vi.fn(), completeScheduleRun: vi.fn(), + completeTaskScheduleRun: vi.fn(), branchLocalConversation: vi.fn(() => ({ id: '00000000-0000-4000-8000-000000000403', branch: { @@ -5367,7 +5368,7 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) - it('reuses a scheduled Task and writes text results to its Conversation', async () => { + it('dispatches scheduled messages through ordinary agent runs with current history and tools', async () => { const fullOutput = `${'x'.repeat(1_000_001)} scheduled tail` const taskId = '00000000-0000-4000-8000-000000000701' const conversationId = @@ -5380,7 +5381,7 @@ describe('registerIpcHandlers agent terminal state', () => { capability: 'chat', supportsToolExecution: true, async *run( - request: { requestId: string }, + request: { requestId: string; history?: unknown; workMode?: string }, _signal: AbortSignal, authorize: ( input: { @@ -5391,11 +5392,13 @@ describe('registerIpcHandlers agent terminal state', () => { ) => Promise ) { expect(request.requestId).toBe(runId) - await authorize({ + expect(request.history).toEqual([{ role: 'user', content: 'Earlier conversation' }]) + expect(request.workMode).toBe('execute') + expect(await authorize({ scopeKey: 'workspace.write', title: '写入工作区', description: '更新状态文件' - }) + })).toBe('once') yield { requestId: request.requestId, type: 'text', @@ -5408,6 +5411,8 @@ describe('registerIpcHandlers agent terminal state', () => { } } const harness = createHarness(runtime) + await electronMocks.handlers.get(ipcChannels.conversationQueueReady)?.( + trustedEvent(harness.webContents), conversationId) const schedule = { id: scheduleId, projectId: '00000000-0000-4000-8000-000000000401', @@ -5435,6 +5440,7 @@ describe('registerIpcHandlers agent terminal state', () => { harness.assistantDatabase.queueScheduleNow.mockReturnValue( queueItem ) + harness.assistantDatabase.getConversationQueueItem.mockReturnValue(queueItem) harness.assistantDatabase.listConversationQueueItems .mockReturnValueOnce([queueItem]) .mockReturnValue([]) @@ -5450,34 +5456,33 @@ describe('registerIpcHandlers agent terminal state', () => { ipcChannels.schedulesRunNow )?.(trustedEvent(harness.webContents), scheduleId) + expect(harness.assistantDatabase.createTask).not.toHaveBeenCalled() + expect(harness.webContents.send).toHaveBeenCalledWith( + ipcChannels.conversationQueueDispatch, + { item: queueItem, scheduled: true, input: { + conversationId, projectId: schedule.projectId, prompt: schedule.prompt + } }) + await harness.handler?.(trustedEvent(harness.webContents), { + requestId: runId, conversationId, queueItemId: queueItem.id, + prompt: schedule.prompt, workMode: 'execute', + history: [{ role: 'user', content: 'Earlier conversation' }] + }) await vi.waitFor(() => expect( - harness.assistantDatabase.completeScheduleRun - ).toHaveBeenCalledWith(runId, 'completed') + harness.assistantDatabase.completeTaskScheduleRun + ).toHaveBeenCalledWith(runId) ) expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(taskId, 'running') - expect(harness.approvalBroker.request).toHaveBeenCalledWith( - expect.objectContaining({ - requestId: taskId, - conversationId - }), - expect.any(AbortSignal), - expect.any(Function) - ) + expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect( harness.assistantDatabase.appendConversationMessage - ).toHaveBeenCalledWith({ - conversationId, - role: 'assistant', - content: fullOutput, - status: '定时任务', - task: { - id: taskId, - title: '每日状态' - } - }) + ).not.toHaveBeenCalled() + expect(harness.webContents.send).toHaveBeenCalledWith( + ipcChannels.agentEvent, expect.objectContaining({ + requestId: runId, type: 'text', delta: fullOutput + })) expect( harness.assistantDatabase.createTextArtifact ).not.toHaveBeenCalled() @@ -5487,7 +5492,7 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) - it('fails scheduled runs promptly when a Runtime asks an interactive question', async () => { + it('allows scheduled messages to ask questions through the ordinary conversation UI', async () => { const taskId = '00000000-0000-4000-8000-000000000711' const conversationId = '00000000-0000-4000-8000-000000000712' @@ -5515,10 +5520,12 @@ describe('registerIpcHandlers agent terminal state', () => { } ] } as const - await new Promise(() => undefined) + yield { requestId: request.requestId, type: 'done' } as const } } const harness = createHarness(runtime) + await electronMocks.handlers.get(ipcChannels.conversationQueueReady)?.( + trustedEvent(harness.webContents), conversationId) const schedule = { id: scheduleId, projectId: '00000000-0000-4000-8000-000000000401', @@ -5546,6 +5553,7 @@ describe('registerIpcHandlers agent terminal state', () => { harness.assistantDatabase.queueScheduleNow.mockReturnValue( queueItem ) + harness.assistantDatabase.getConversationQueueItem.mockReturnValue(queueItem) harness.assistantDatabase.listConversationQueueItems .mockReturnValueOnce([queueItem]) .mockReturnValue([]) @@ -5560,25 +5568,20 @@ describe('registerIpcHandlers agent terminal state', () => { ipcChannels.schedulesRunNow )?.(trustedEvent(harness.webContents), scheduleId) + await harness.handler?.(trustedEvent(harness.webContents), { + requestId: runId, conversationId, queueItemId: queueItem.id, + prompt: schedule.prompt, workMode: 'execute' + }) await vi.waitFor(() => expect( - harness.assistantDatabase.completeScheduleRun - ).toHaveBeenCalledWith(runId, 'failed') - ) - expect(respondToQuestion).toHaveBeenCalledWith( - 'opencode-background-question' - ) - expect( - harness.assistantDatabase.appendConversationMessage - ).toHaveBeenCalledWith( - expect.objectContaining({ - conversationId, - state: 'error', - status: '定时任务失败', - content: - '后台任务无法回答 Runtime 交互提问。请改为在 GoodBuddy 对话中运行,或调整提示词和工具配置以避免交互提问。' - }) + harness.assistantDatabase.completeTaskScheduleRun + ).toHaveBeenCalledWith(runId) ) + expect(respondToQuestion).not.toHaveBeenCalled() + expect(harness.webContents.send).toHaveBeenCalledWith( + ipcChannels.agentEvent, expect.objectContaining({ + type: 'question', questionId: 'opencode-background-question' + })) await harness.dispose() }) @@ -8406,6 +8409,42 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) + it('resolves the latest project rule for ordinary runs and lets explicit selections override it', async () => { + const requestRuntime = { + runtimeId: 'model', capability: 'chat', supportsToolExecution: true, + async *run(request: { requestId: string }) { + yield { requestId: request.requestId, type: 'done' } + } + } + const selectedRuntimes = { + getRuntime: vi.fn(async () => requestRuntime), + releaseConversation: vi.fn(async () => undefined) + } + const harness = createHarness(requestRuntime, undefined, 'always', undefined, false, selectedRuntimes) + const projectId = '00000000-0000-4000-8000-000000000101' + const fixed = { provider: 'continue', profileId: '00000000-0000-4000-8000-000000000042' } + const project = { id: projectId, rootPath: 'C:\\ProjectWorkspace' } + harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: { provider: 'opencode' } }) + const event = trustedEvent(harness.webContents) + await harness.handler?.(event, { + projectId, conversationId: 'project-rule-one', + requestId: '00000000-0000-4000-8000-000000000011', prompt: 'one', workMode: 'ask' + }) + expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith({ provider: 'opencode' }, expect.anything()) + harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: fixed }) + await harness.handler?.(event, { + projectId, conversationId: 'project-rule-two', + requestId: '00000000-0000-4000-8000-000000000012', prompt: 'two', workMode: 'ask' + }) + expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith(fixed, expect.anything()) + await harness.handler?.(event, { + projectId, conversationId: 'project-rule-three', runtimeSelection: { provider: 'model' }, + requestId: '00000000-0000-4000-8000-000000000013', prompt: 'three', workMode: 'ask' + }) + expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith({ provider: 'model' }, expect.anything()) + await harness.dispose() + }) + it('rejects an active duplicate before resolving Runtime or settings again', async () => { let releaseRun!: () => void const runReleased = new Promise((resolve) => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3fed58b8..3b23e721 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1479,14 +1479,14 @@ export function registerIpcHandlers( if (executionSpace?.kind === 'ssh' && !selectedRuntimes) { throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE) } + let selection = request.runtimeSelection ?? project?.runtimeSelection if ( !selectedRuntimes || - (!request.runtimeSelection && !executionSpace) + (!selection && !executionSpace) ) { return runtime } - let selection = - request.runtimeSelection ?? ({ provider: 'auto' } as const) + selection ??= { provider: 'auto' } if (request.followConfiguredAgentRuntime) { selection = resolveConfiguredAgentRuntimeSelection( await settingsStore.getResolvedSettings(), @@ -2107,6 +2107,7 @@ export function registerIpcHandlers( throw error } finally { lease.release() + assistantDatabase.completeTaskScheduleRun(task.taskId) } } const startRemoteProjectRecovery = ( @@ -2235,8 +2236,6 @@ export function registerIpcHandlers( return conversationQueueUserInputSchema.parse(parsed) } const pumpingConversationQueues = new Set() - const maximumConcurrentScheduleRuns = 4 - let activeScheduleRuns = 0 const isConversationExecuting = ( conversationId: string @@ -2272,17 +2271,10 @@ export function registerIpcHandlers( return } if ( - pendingItem.source === 'user' && !rendererReadyConversationQueues.has(conversationId) ) { return } - if ( - pendingItem.source === 'schedule' && - activeScheduleRuns >= maximumConcurrentScheduleRuns - ) { - return - } pumpingConversationQueues.add(conversationId) try { if (isConversationExecuting(conversationId)) { @@ -2298,7 +2290,7 @@ export function registerIpcHandlers( readyConversationQueues.delete(conversationId) preferredConversationQueueItems.delete(conversationId) publishConversationQueueChange(conversationId) - if (claimed.source === 'user') { + { if (window.isDestroyed()) { assistantDatabase.releaseConversationUserQueueItem( claimed.item.id @@ -2306,12 +2298,22 @@ export function registerIpcHandlers( readyConversationQueues.add(conversationId) return } - let input: ConversationQueueUserInput + let dispatch: ConversationQueueDispatch try { - input = parseConversationQueueUserPayload( - claimed.payloadJson, - true - ) + dispatch = claimed.source === 'schedule' + ? { + item: claimed.item, + scheduled: true, + input: { + conversationId, + projectId: claimed.schedule.projectId, + prompt: claimed.schedule.prompt + } + } + : { + item: claimed.item, + input: parseConversationQueueUserPayload(claimed.payloadJson, true) + } } catch { assistantDatabase.releaseConversationUserQueueItem( claimed.item.id @@ -2340,7 +2342,7 @@ export function registerIpcHandlers( } catch { return } - for (const attachment of input.attachments) { + for (const attachment of dispatch.scheduled ? [] : dispatch.input.attachments) { contextManager.remove(attachment.id) } readyConversationQueues.add(conversationId) @@ -2348,10 +2350,6 @@ export function registerIpcHandlers( void pumpConversationQueue(conversationId) }, 30_000) queueDispatchTimers.set(claimed.item.id, dispatchTimeout) - const dispatch: ConversationQueueDispatch = { - item: claimed.item, - input - } window.webContents.send( ipcChannels.conversationQueueDispatch, dispatch @@ -2359,38 +2357,6 @@ export function registerIpcHandlers( return } - activeScheduleRuns += 1 - const execution = (async () => { - const result = await executeTaskWork({ - origin: 'schedule', - schedule: claimed.schedule, - scheduleRunId: claimed.runId - }) - assistantDatabase.completeScheduleRun( - claimed.runId, - result.status - ) - publishConversationChange() - })() - publishConversationChange() - void trackExecution(execution) - .catch(() => undefined) - .finally(() => { - activeScheduleRuns -= 1 - readyConversationQueues.add(conversationId) - publishConversationQueueChange(conversationId) - void pumpConversationQueue(conversationId) - for (const pendingConversationId of - assistantDatabase.listPendingScheduleQueueConversationIds( - maximumConcurrentScheduleRuns - )) { - if ( - readyConversationQueues.has(pendingConversationId) - ) { - void pumpConversationQueue(pendingConversationId) - } - } - }) } finally { pumpingConversationQueues.delete(conversationId) } @@ -2416,12 +2382,6 @@ export function registerIpcHandlers( resultFileRequested?: boolean } type TaskWorkExecution = - | { - origin: 'schedule' - schedule: AssistantSchedule - scheduleRunId: string - externalSignal?: AbortSignal - } | { origin: 'delegation' schedule: ExecutionTemplate @@ -2454,13 +2414,10 @@ export function registerIpcHandlers( return { status: 'cancelled', error: '请求已取消' } } const taskId = - input.origin === 'schedule' - ? input.schedule.taskId - : input.origin === 'channel' + input.origin === 'channel' ? input.remoteContext.taskId : randomUUID() - const requestId = - input.origin === 'schedule' ? input.scheduleRunId : taskId + const requestId = taskId const controller = new AbortController() const abortFromExternal = (): void => { controller.abort(externalSignal?.reason) @@ -2470,9 +2427,6 @@ export function registerIpcHandlers( }) const runtimeConversationId = remoteContext?.conversationId ?? - (input.origin === 'schedule' - ? input.schedule.conversationId - : undefined) ?? `${origin}:${schedule.id}` const activeRequestLease = leaseActiveRequest( requestId, @@ -2493,9 +2447,6 @@ export function registerIpcHandlers( visible: false }) } - if (origin === 'schedule') { - publishConversationChange() - } let output = '' let completed = false let backgroundQuestionError: Error | undefined @@ -2602,9 +2553,6 @@ export function registerIpcHandlers( taskId, 'waiting_approval' ) - if (origin === 'schedule') { - publishConversationChange() - } const settings = await settingsStore.getPolicySettings() try { return await approvalBroker.request( @@ -2614,8 +2562,7 @@ export function registerIpcHandlers( settings.toolApproval === 'policy' ? 'policy' : undefined, - requestId: - origin === 'schedule' ? taskId : requestId, + requestId, conversationId: runtimeConversationId }, activeSignal, @@ -2635,9 +2582,6 @@ export function registerIpcHandlers( !activeSignal.aborted ) { assistantDatabase.updateTaskStatus(taskId, 'running') - if (origin === 'schedule') { - publishConversationChange() - } } } } @@ -2690,10 +2634,7 @@ export function registerIpcHandlers( persistModelUsage({ ...agentEvent, requestId: taskId, - callId: - origin === 'schedule' - ? `${requestId}:${agentEvent.callId}` - : agentEvent.callId + callId: agentEvent.callId }) if (provenance !== undefined) { assistantDatabase.appendRemoteTaskEventOnce({ @@ -2864,25 +2805,7 @@ export function registerIpcHandlers( }) } } - if ( - input.origin === 'schedule' && - (output.trim() || artifactIds.length > 0) - ) { - assistantDatabase.appendConversationMessage({ - conversationId: input.schedule.conversationId, - role: 'assistant', - content: - output.trim() || - '任务已完成,独立成果已保存到成果工作栏。', - status: '定时任务', - ...(artifactIds.length > 0 ? { artifactIds } : {}), - task: { - id: taskId, - title: schedule.title - } - }) - publishConversationChange() - } else if (origin === 'delegation' && output.trim()) { + if (origin === 'delegation' && output.trim()) { assistantDatabase.createTextArtifact({ projectId: schedule.projectId, taskId, @@ -2890,9 +2813,7 @@ export function registerIpcHandlers( content: output }) } - if (origin !== 'schedule') { - assistantDatabase.updateTaskStatus(taskId, 'completed') - } + assistantDatabase.updateTaskStatus(taskId, 'completed') showDesktopNotificationWhenUnfocused(window, { title: origin === 'channel' @@ -2901,9 +2822,7 @@ export function registerIpcHandlers( body: origin === 'channel' ? '结果已回复,并保存到远程通道会话。' - : origin === 'schedule' - ? '结果已写入关联对话。' - : '结果已保存到成果工作栏和委派记录。' + : '结果已保存到成果工作栏和委派记录。' }) return { status: 'completed', @@ -2925,20 +2844,6 @@ export function registerIpcHandlers( cancelled ? 'cancelled' : 'failed', message ) - if (input.origin === 'schedule') { - assistantDatabase.appendConversationMessage({ - conversationId: input.schedule.conversationId, - role: 'assistant', - content: message, - state: 'error', - status: cancelled ? '定时任务已取消' : '定时任务失败', - task: { - id: taskId, - title: schedule.title - } - }) - publishConversationChange() - } showDesktopNotificationWhenUnfocused(window, { title: origin === 'channel' @@ -4022,7 +3927,8 @@ export function registerIpcHandlers( if ( parsedInput.queueItemId && (!queuedItem || - queuedItem.source !== 'user' || + (queuedItem.source === 'schedule' && + parsedInput.requestId !== queuedItem.scheduleRunId) || queuedItem.conversationId !== parsedInput.conversationId || !assistantDatabase.isConversationUserQueueItemDispatching( parsedInput.queueItemId @@ -4265,6 +4171,9 @@ export function registerIpcHandlers( assistantDatabase.completeConversationUserQueueItem( parsedInput.queueItemId ) + if (queuedItem?.source === 'schedule' && queuedItem.taskId) { + assistantDatabase.updateTaskStatus(queuedItem.taskId, 'running') + } publishConversationQueueChange(request.conversationId) } catch (error) { activeRequestLease.release() @@ -4880,6 +4789,10 @@ export function registerIpcHandlers( } finally { eventBuffer.close() activeEventBuffers.delete(request.requestId) + if (queuedItem?.source === 'schedule' && !remoteRecoveryPending) { + assistantDatabase.completeTaskScheduleRun(request.requestId) + publishConversationChange() + } for (const [questionId, pending] of pendingAgentQuestions) { if (pending.requestId === request.requestId) { pendingAgentQuestions.delete(questionId) diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index 5748eefa..8dfef1c1 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -163,6 +163,30 @@ describe('RuntimeSettingsStore', () => { }) }) + it('round-trips the Harness platform rule without pinning its resolved fallback', async () => { + const { store, filePath } = await createStore() + const firstId = '00000000-0000-4000-8000-000000000041' + const secondId = '00000000-0000-4000-8000-000000000042' + const modelProfiles = [firstId, secondId].map((id) => ({ + id, name: id, baseUrl: 'https://gateway.example/v1', modelName: id, + protocol: 'openai-chat-completions' as const, + authentication: 'api-key' as const, + imageGenerationQuality: 'auto' as const, + apiKey: { action: 'keep' as const } + })) + await store.update(settings({ modelProfiles, defaultModelProfileId: firstId })) + const publicSettings = await store.getPublicSettings() + const started = await store.getResolvedSettings() + await store.update(settings({ + defaultModelProfileId: secondId, + deepseekHarnessModelSource: publicSettings.deepseekHarnessModelSource + })) + const reloaded = new RuntimeSettingsStore(filePath, cipher, {}) + expect((await reloaded.getPublicSettings()).deepseekHarnessModelSource).toEqual({ kind: 'platform' }) + expect((await reloaded.getResolvedSettings()).deepseekHarnessModelProfile?.id).toBe(secondId) + expect(started.deepseekHarnessModelProfile?.id).toBe(firstId) + }) + it('seeds a local provider-neutral model connection', async () => { const { store } = await createStore() @@ -613,7 +637,7 @@ describe('RuntimeSettingsStore', () => { ) await expect(store.getPublicSettings()).resolves.toMatchObject({ - deepseekHarnessModelSource: { kind: 'profile', profileId }, + deepseekHarnessModelSource: { kind: 'platform' }, configured: { deepseekHarnessModelSource: { kind: 'platform' } } diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index 33ae038d..1ffb4579 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -1739,20 +1739,7 @@ export class RuntimeSettingsStore { credentialSource: credential.source } }) - const deepseekHarnessModelProfile = - this.resolveDeepSeekHarnessModelProfile( - settings, - resolvedModelProfiles - ) - const deepseekHarnessModelSource = - settings.deepseekHarnessModelSource.kind === 'platform' && - deepseekHarnessModelProfile && - resolvedProfilesById.has(deepseekHarnessModelProfile.id) - ? { - kind: 'profile' as const, - profileId: deepseekHarnessModelProfile.id - } - : settings.deepseekHarnessModelSource + const deepseekHarnessModelSource = settings.deepseekHarnessModelSource const embeddingEnvironmentApiKey = this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim() const embeddingStoredApiKey = embeddingEnvironmentApiKey diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index b995878b..5f19646c 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -1684,7 +1684,7 @@ describe("App", () => { ).not.toBeInTheDocument(); expect( taskChild?.querySelector(".conversation-task-child__meta"), - ).toHaveTextContent("Execute · 每周 · 空闲"); + ).toHaveTextContent("每周 · 空闲"); expect(screen.queryByText("Run")).not.toBeInTheDocument(); fireEvent.click(taskChild!); const taskRegion = await screen.findByRole("region", { @@ -1883,7 +1883,7 @@ describe("App", () => { expect(completedTask).toHaveAccessibleName(/已完成/u); expect( completedTask?.querySelector(".conversation-task-child__meta"), - ).toHaveTextContent("Ask · 仅一次"); + ).toHaveTextContent("仅一次"); expect( completedTask?.querySelector(".conversation-task-child__meta"), ).not.toHaveTextContent("已完成"); @@ -5040,6 +5040,86 @@ describe("App", () => { expect(runtimeButton).toBeEnabled(); }); + it("sends scheduled instructions with the target conversation's current settings and history", async () => { + const conversationId = crypto.randomUUID(); + const runId = crypto.randomUUID(); + const knowledgeId = crypto.randomUUID(); + const historyId = crypto.randomUUID(); + vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({ + libraries: [{ + id: knowledgeId, name: "Scheduled knowledge", description: "", + storageMode: "managed", graphEnabled: false, graphStrategy: "rules", + sourceCount: 1, documentCount: 1, indexedDocumentCount: 1, + }], + sources: [], documents: [], graphNodes: [], graphRelations: [], evidence: [], + }); + vi.mocked(api.conversations.list).mockResolvedValue([{ + id: conversationId, projectId, title: "Scheduled target", updatedAt: Date.now(), + runtimeSelection: { provider: "continue" }, workMode: "execute", + knowledgeLibraryIds: [knowledgeId], knowledgeRetrievalMode: "always", + messages: [{ id: historyId, role: "user", content: "Existing context", state: "complete", createdAt: Date.now() }], + }]); + render(); + await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith(conversationId)); + act(() => conversationQueueDispatchListener?.({ + scheduled: true, + item: { id: runId, scheduleRunId: runId, conversationId, source: "schedule", label: "Timed message", createdAt: new Date().toISOString() }, + input: { conversationId, projectId, prompt: "Use existing context" }, + })); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + expect(run.mock.calls[0]![0]).toMatchObject({ + requestId: runId, queueItemId: runId, conversationId, + runtimeSelection: { provider: "continue" }, workMode: "execute", + knowledgeLibraryIds: [knowledgeId], knowledgeRetrievalMode: "always", + history: [{ role: "user", content: "Existing context" }], historyMessageIds: [historyId], + contextIds: [], + }); + expect(api.conversationQueue.enqueueUser).not.toHaveBeenCalled(); + expect(await screen.findByText("Use existing context")).toBeInTheDocument(); + act(() => { + agentListener?.({ requestId: runId, type: "text", delta: "Latest reply" }); + agentListener?.({ requestId: runId, type: "done" }); + }); + await screen.findByText("Latest reply"); + const nextRunId = crypto.randomUUID(); + act(() => conversationQueueDispatchListener?.({ + scheduled: true, + item: { id: nextRunId, scheduleRunId: nextRunId, conversationId, source: "schedule", label: "Timed message", createdAt: new Date().toISOString() }, + input: { conversationId, projectId, prompt: "Continue again" }, + })); + await waitFor(() => expect(run).toHaveBeenCalledTimes(2)); + expect(run.mock.calls[1]![0]).toMatchObject({ + conversationId, + history: [ + { role: "user", content: "Existing context" }, + { role: "user", content: "Use existing context" }, + { role: "assistant", content: "Latest reply" }, + ], + }); + }); + + it("uses normal project defaults for a new scheduled conversation without another conversation's context", async () => { + const targetId = crypto.randomUUID(); + const runId = crypto.randomUUID(); + vi.mocked(api.projects.list).mockResolvedValueOnce([{ ...project, defaultWorkMode: "execute", runtimeSelection: { provider: "opencode" } }]); + vi.mocked(api.conversations.list).mockResolvedValue([ + { id: crypto.randomUUID(), projectId, title: "Other conversation", updatedAt: Date.now(), workMode: "ask", runtimeSelection: { provider: "model" }, messages: [{ id: crypto.randomUUID(), role: "user", content: "Do not inherit this source history", state: "complete", createdAt: Date.now() }] }, + { id: targetId, projectId, title: "New scheduled conversation", updatedAt: Date.now(), messages: [] }, + ]); + render(); + await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith(targetId)); + act(() => conversationQueueDispatchListener?.({ + scheduled: true, + item: { id: runId, scheduleRunId: runId, conversationId: targetId, source: "schedule", label: "Timed message", createdAt: new Date().toISOString() }, + input: { conversationId: targetId, projectId, prompt: "Independent message" }, + })); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + expect(run.mock.calls[0]![0]).toMatchObject({ + conversationId: targetId, runtimeSelection: { provider: "opencode" }, workMode: "execute", + history: [], knowledgeLibraryIds: [], contextIds: [], + }); + }); + it("queues another ordinary message while the Conversation is running", async () => { render(); @@ -6289,6 +6369,50 @@ describe("App", () => { ).toBeInTheDocument(); }); + it("keeps an unselected SSH conversation inheriting the project's fixed model", async () => { + installRemoteProjectsSetting(true); + const remoteProject = { + ...project, + id: "00000000-0000-4000-8000-000000000119", + name: "Fixed remote model", + rootPath: "/srv/project", + runtimeSelection: { provider: "opencode" as const, profileId: modelProfileId }, + executionSpace: { + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000219", + remoteRootPath: "/srv/project", + }, + builtInDefault: false, + }; + const conversationId = "00000000-0000-4000-8000-000000000319"; + vi.mocked(api.projects.list).mockResolvedValueOnce([project, remoteProject]); + vi.mocked(api.conversations.list).mockResolvedValueOnce([{ + id: conversationId, + projectId: remoteProject.id, + title: "Inherited remote model", + updatedAt: 1_775_000_000_000, + messages: [], + }]); + + render(); + await screen.findByRole("button", { name: "当前项目" }); + selectProjectOption(remoteProject.name); + await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledWith( + remoteProject.runtimeSelection, + )); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "Verify inherited model" }, + }); + fireEvent.click(screen.getByRole("button", { name: "发送", exact: true })); + await waitFor(() => { + const saved = vi.mocked(api.conversations.saveLocal).mock.calls + .flatMap(([conversations]) => conversations) + .filter((conversation) => conversation.header.id === conversationId); + expect(saved.length).toBeGreaterThan(0); + expect(saved.every((conversation) => conversation.header.runtimeSelection === undefined)).toBe(true); + }); + }); + it("keeps managed SSH histories visible while independently blocking recovery projects", async () => { installRemoteProjectsSetting(true); const first = { @@ -8957,6 +9081,9 @@ describe("App", () => { await screen.findByRole("button", { name: /第二模型/u }), ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /新建对话/u })); + expect(screen.getByRole("button", { name: /第二模型/u })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "第二模型对话" }, }); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 551b3456..1acc7b3c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1309,6 +1309,7 @@ function toConversationSnapshots( id: conversation.id, projectId: conversation.projectId, runtimeSelection: conversation.runtimeSelection, + workMode: conversation.workMode, knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, contextMetrics: conversation.contextMetrics, @@ -1352,6 +1353,7 @@ function toLocalConversationHeader( id: conversation.id, projectId: conversation.projectId, runtimeSelection: conversation.runtimeSelection, + workMode: conversation.workMode, knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, contextMetrics: conversation.contextMetrics, @@ -2093,7 +2095,6 @@ function App(): React.JSX.Element { >(undefined); const viewRef = useRef("chat"); const heartbeatLoadRequestRef = useRef(0); - const [workMode, setWorkMode] = useState("ask"); const [conversationDrafts, setConversationDrafts] = useState< Record >({}); @@ -2183,10 +2184,6 @@ function App(): React.JSX.Element { const toggleAppearanceTheme = useCallback((): void => { setAppearanceTheme(resolvedAppearanceTheme === "dark" ? "light" : "dark"); }, [resolvedAppearanceTheme]); - const effectiveWorkMode = - workMode === "execute" && runtime?.supportsToolExecution === false - ? "ask" - : workMode; const setExpertMenuOpen = useCallback((open: boolean): void => { setComposerMenuOpen(open ? "expert" : undefined); if (open) { @@ -2707,7 +2704,7 @@ function App(): React.JSX.Element { ); useEffect(() => { - if (!conversationStoreReady || !projectRecoverySnapshotReady) { + if (!conversationStoreReady || !projectRecoverySnapshotReady || !runtimeSettings) { return; } const conversationIds = [ @@ -2738,7 +2735,7 @@ function App(): React.JSX.Element { dedupeKey: "conversation-queue-resume", }); }); - }, [conversationStoreReady, projectRecoverySnapshotReady]); + }, [conversationStoreReady, projectRecoverySnapshotReady, conversations.length, runtimeSettings]); useEffect(() => { const sweep = (): void => { @@ -3118,6 +3115,20 @@ function App(): React.JSX.Element { [activeProjectId, projects], ); const activeProjectUsesManagedSsh = isManagedSshProject(activeProject); + const workMode = normalizeInteractiveWorkMode( + activeConversation?.workMode ?? activeProject?.defaultWorkMode, + ); + const effectiveWorkMode = + workMode === "execute" && runtime?.supportsToolExecution === false + ? "ask" + : workMode; + const setWorkMode = (mode: InteractiveWorkMode): void => { + setConversations((current) => current.map((conversation) => + conversation.id === activeId + ? { ...conversation, workMode: mode, updatedAt: Date.now() } + : conversation, + )); + }; const activeProjectRecovery = activeProjectUsesManagedSsh && activeProject ? projectRecoveryByProjectId[activeProject.id] @@ -5435,7 +5446,6 @@ function App(): React.JSX.Element { throw new Error("没有可用的本地项目"); } setActiveProjectId(project.id); - setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); const persistedLocalConversations = persistedConversations.filter( (conversation) => !conversation.remote, ); @@ -6077,7 +6087,6 @@ function App(): React.JSX.Element { candidateConversations = conversationsRef.current, ): void => { setActiveProjectId(selected.id); - setWorkMode(normalizeInteractiveWorkMode(selected.defaultWorkMode)); const conversation = candidateConversations.find( (candidate) => candidate.projectId === selected.id && @@ -6086,11 +6095,12 @@ function App(): React.JSX.Element { if (conversation) { if (isManagedSshProject(selected)) { const runtimeSelection = runtimeSettings - ? getRuntimeSelectionForProvider("opencode", runtimeSettings) + ? getProjectDefaultRuntimeSelection(selected, runtimeSettings) : ({ provider: "opencode" } as const); setConversations((current) => current.map((candidate) => candidate.projectId === selected.id && + candidate.runtimeSelection !== undefined && candidate.runtimeSelection?.provider !== "opencode" ? { ...candidate, @@ -6140,7 +6150,6 @@ function App(): React.JSX.Element { const project = await window.goodbuddy.projects.create(input); setProjects((current) => [project, ...current]); setActiveProjectId(project.id); - setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); const conversation = createConversation( project.id, undefined, @@ -6162,9 +6171,6 @@ function App(): React.JSX.Element { candidate.id === project.id ? project : candidate, ), ); - if (project.id === activeProjectId) { - setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); - } return project; }; @@ -6766,7 +6772,36 @@ function App(): React.JSX.Element { const submit = async ( queuedDispatch?: ConversationQueueDispatch, ): Promise => { - const queuedInput = queuedDispatch?.input; + let queuedInput: ConversationQueueUserInput | undefined = + queuedDispatch && !queuedDispatch.scheduled ? queuedDispatch.input : undefined; + if (queuedDispatch?.scheduled) { + const conversation = conversationsRef.current.find( + (candidate) => candidate.id === queuedDispatch.input.conversationId, + ); + const project = projectsRef.current.find( + (candidate) => candidate.id === conversation?.projectId, + ); + const selection = conversation?.runtimeSelection ?? + (runtimeSettings ? getProjectDefaultRuntimeSelection(project, runtimeSettings) : undefined); + if (!conversation || !selection) { + await window.goodbuddy.conversationQueue.releaseUser(queuedDispatch.item.id); + return; + } + const mode = normalizeInteractiveWorkMode(conversation.workMode ?? project?.defaultWorkMode); + queuedInput = { + conversationId: conversation.id, + projectId: conversation.projectId, + prompt: queuedDispatch.input.prompt, + runtimeSelection: selection, + workMode: mode, + includeMemoryContext: true, + attachments: [], + knowledgeLibraryIds: conversation.knowledgeLibraryIds ?? [], + knowledgeRetrievalMode: conversation.knowledgeRetrievalMode ?? "auto", + smartRouting: runtimeSettings?.subagentSmartRoutingEnabled === true && + supportsSubagentSmartRouting(mode) ? true : undefined, + }; + } const releaseQueuedItem = async (): Promise => { if (!queuedDispatch) { return; @@ -6856,7 +6891,9 @@ function App(): React.JSX.Element { return; } - const requestId = crypto.randomUUID(); + const requestId = queuedDispatch?.scheduled + ? queuedDispatch.item.scheduleRunId! + : crypto.randomUUID(); const conversationId = conversationSnapshot.id; const attachmentSnapshot = (queuedInput?.attachments ?? attachments).slice( 0, @@ -6970,7 +7007,7 @@ function App(): React.JSX.Element { return; } - if (dispatchedConversationQueueItems.current.has(queuedDispatch.item.id)) { + if (!queuedDispatch || dispatchedConversationQueueItems.current.has(queuedDispatch.item.id)) { return; } dispatchedConversationQueueItems.current.add(queuedDispatch.item.id); @@ -7655,13 +7692,7 @@ function App(): React.JSX.Element { return; } if (conversation.projectId) { - const project = projects.find( - (candidate) => candidate.id === conversation.projectId, - ); setActiveProjectId(conversation.projectId); - if (project) { - setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); - } } setActiveId(conversationId); setUnreadConversationIds((current) => { @@ -7694,13 +7725,7 @@ function App(): React.JSX.Element { return; } if (task.projectId) { - const project = projects.find( - (candidate) => candidate.id === task.projectId, - ); setActiveProjectId(task.projectId); - if (project) { - setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); - } } setSelectedAssistantTaskId(task.id); setExpandedTaskConversationIds((current) => { @@ -7782,13 +7807,7 @@ function App(): React.JSX.Element { }); } if (schedule.projectId) { - const project = projects.find( - (candidate) => candidate.id === schedule.projectId, - ); setActiveProjectId(schedule.projectId); - if (project) { - setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); - } } setActiveId(schedule.conversationId); setView("chat"); @@ -8591,7 +8610,6 @@ function App(): React.JSX.Element { ); const metadata = schedule ? [ - tWorkspace(`task.mode.${schedule.workMode}`), tWorkspace( `sidebar.tasks.schedule.recurrence.${schedule.recurrence}`, ), @@ -10747,8 +10765,6 @@ function App(): React.JSX.Element { onCreate={createCustomTask} projectId={activeProject.id} projectName={activeProjectDisplayName ?? activeProject.name} - runtimeLabel={activeRuntimeLabel} - supportsToolExecution={Boolean(runtime?.supportsToolExecution)} workspaceLabel={ activeProject.rootPath || t("customTask.scope.noWorkspace") } diff --git a/src/renderer/src/CustomTaskDialog.test.tsx b/src/renderer/src/CustomTaskDialog.test.tsx index 5e7ed61e..59a47491 100644 --- a/src/renderer/src/CustomTaskDialog.test.tsx +++ b/src/renderer/src/CustomTaskDialog.test.tsx @@ -39,7 +39,7 @@ describe('CustomTaskDialog', () => { ) }) - it('defaults to Execute and keeps the current Conversation association', async () => { + it('queues a timed message without capturing conversation execution settings', async () => { const conversationId = '00000000-0000-4000-8000-000000000801' const onCreate = vi.fn(async (input) => ({ @@ -61,8 +61,6 @@ describe('CustomTaskDialog', () => { onCreate={onCreate} projectId="00000000-0000-4000-8000-000000000804" projectName="GoodBuddy Desktop" - runtimeLabel="OpenCode" - supportsToolExecution workspaceLabel="C:\\Workspace" /> ) @@ -70,10 +68,7 @@ describe('CustomTaskDialog', () => { expect( screen.getByRole('dialog', { name: '新建定制任务' }) ).toHaveAttribute('aria-modal', 'true') - expect(screen.getByRole('button', { name: 'Execute' })).toHaveAttribute( - 'aria-pressed', - 'true' - ) + expect(screen.queryByRole('button', { name: 'Execute' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: '当前会话' })).toHaveAttribute( 'aria-pressed', 'true' @@ -97,13 +92,14 @@ describe('CustomTaskDialog', () => { conversationId, title: '每周项目总结', prompt: '总结完成和失败的工作', - workMode: 'execute', recurrence: 'once' }) ) + expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('workMode') + expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('runtimeSelection') }) - it('uses Ask when the selected Runtime cannot execute tools', () => { + it('creates an independent conversation using normal defaults', () => { render( { onClose={vi.fn()} onCreate={vi.fn()} projectName="GoodBuddy Desktop" - runtimeLabel="Direct model" - supportsToolExecution={false} workspaceLabel="C:\\Workspace" /> ) - expect(screen.getByRole('button', { name: 'Execute' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'Ask' })).toHaveAttribute( - 'aria-pressed', - 'true' - ) + expect(screen.queryByRole('button', { name: 'Ask' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: '当前会话' })).toBeDisabled() }) }) diff --git a/src/renderer/src/CustomTaskDialog.tsx b/src/renderer/src/CustomTaskDialog.tsx index 4b287e66..8a5b24e8 100644 --- a/src/renderer/src/CustomTaskDialog.tsx +++ b/src/renderer/src/CustomTaskDialog.tsx @@ -17,9 +17,7 @@ type CustomTaskDialogProps = { defaultDestination: CustomTaskDestination projectId?: string projectName: string - runtimeLabel: string workspaceLabel: string - supportsToolExecution: boolean onClose: () => void onCreate: (input: ScheduleCreateInput) => Promise } @@ -35,9 +33,7 @@ export function CustomTaskDialog({ defaultDestination, projectId, projectName, - runtimeLabel, workspaceLabel, - supportsToolExecution, onClose, onCreate }: CustomTaskDialogProps): React.JSX.Element { @@ -51,10 +47,6 @@ export function CustomTaskDialog({ ? 'current' : 'new' ) - const [workMode, setWorkMode] = - useState( - supportsToolExecution ? 'execute' : 'ask' - ) const [recurrence, setRecurrence] = useState('once') const [nextRunAt, setNextRunAt] = useState(() => @@ -119,7 +111,6 @@ export function CustomTaskDialog({ : {}), title: title.trim(), prompt: prompt.trim(), - workMode, recurrence, nextRunAt: runAt.toISOString() }) @@ -254,25 +245,6 @@ export function CustomTaskDialog({
-
- {t('customTask.fields.mode')} - - {!supportsToolExecution && ( - {t('customTask.mode.executeUnavailable')} - )} -