fix: preserve conversation settings for models and scheduled messages
Preserve explicit Runtime model selections and Harness configuration rules without pinning resolved defaults. Expose fixed Runtime models in project settings and keep SSH conversations inheriting their project selection. Dispatch scheduled instructions through ordinary conversation requests using current history, Runtime, work mode, and knowledge scope. Persist conversation work modes and update scheduling documentation and regression tests. Validation checkpoint: typecheck, lint, and 104 focused tests passed. The SSH inheritance regression passed in isolation, but its process timed out on exit. Full-suite completion, remaining model documentation review, and real end-to-end validation are still pending. Release note: Preserve fixed model choices and let scheduled messages use their conversation's current settings and history.
This commit is contained in:
@@ -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 个用户可提交的待执行项。启动时将未完成的派发恢复为
|
||||
|
||||
@@ -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 中显示为独立层级。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
+81
-42
@@ -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<string>
|
||||
) {
|
||||
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<void>((resolve) => {
|
||||
|
||||
+37
-124
@@ -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<string>()
|
||||
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)
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<App />);
|
||||
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(<App />);
|
||||
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(<App />);
|
||||
|
||||
@@ -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(<App />);
|
||||
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: "第二模型对话" },
|
||||
});
|
||||
|
||||
+54
-38
@@ -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<WorkspaceView>("chat");
|
||||
const heartbeatLoadRequestRef = useRef(0);
|
||||
const [workMode, setWorkMode] = useState<InteractiveWorkMode>("ask");
|
||||
const [conversationDrafts, setConversationDrafts] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<CustomTaskDialog
|
||||
currentConversationAvailable={false}
|
||||
@@ -111,17 +107,11 @@ describe('CustomTaskDialog', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,9 +17,7 @@ type CustomTaskDialogProps = {
|
||||
defaultDestination: CustomTaskDestination
|
||||
projectId?: string
|
||||
projectName: string
|
||||
runtimeLabel: string
|
||||
workspaceLabel: string
|
||||
supportsToolExecution: boolean
|
||||
onClose: () => void
|
||||
onCreate: (input: ScheduleCreateInput) => Promise<AssistantSchedule>
|
||||
}
|
||||
@@ -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<ScheduleCreateInput['workMode']>(
|
||||
supportsToolExecution ? 'execute' : 'ask'
|
||||
)
|
||||
const [recurrence, setRecurrence] =
|
||||
useState<ScheduleCreateInput['recurrence']>('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({
|
||||
</div>
|
||||
|
||||
<div className="custom-task-dialog__two-columns">
|
||||
<div className="custom-task-dialog__choice">
|
||||
<span>{t('customTask.fields.mode')}</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('customTask.fields.mode')}
|
||||
onChange={setWorkMode}
|
||||
options={[
|
||||
{
|
||||
value: 'execute',
|
||||
label: t('customTask.mode.execute'),
|
||||
disabled: !supportsToolExecution
|
||||
},
|
||||
{ value: 'ask', label: t('customTask.mode.ask') }
|
||||
]}
|
||||
value={workMode}
|
||||
/>
|
||||
{!supportsToolExecution && (
|
||||
<small>{t('customTask.mode.executeUnavailable')}</small>
|
||||
)}
|
||||
</div>
|
||||
<label className="custom-task-dialog__field">
|
||||
<span>{t('customTask.fields.recurrence')}</span>
|
||||
<select
|
||||
@@ -325,10 +297,6 @@ export function CustomTaskDialog({
|
||||
<dt>{t('customTask.scope.project')}</dt>
|
||||
<dd>{projectName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('customTask.scope.runtime')}</dt>
|
||||
<dd>{runtimeLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('customTask.scope.workspace')}</dt>
|
||||
<dd>{workspaceLabel}</dd>
|
||||
@@ -337,9 +305,7 @@ export function CustomTaskDialog({
|
||||
<dt>{t('customTask.scope.tools')}</dt>
|
||||
<dd>
|
||||
<ShieldCheck aria-hidden="true" size={13} />
|
||||
{workMode === 'execute'
|
||||
? t('customTask.scope.executeApproval')
|
||||
: t('customTask.scope.askReadOnly')}
|
||||
{t('customTask.scope.conversationSettings')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defaultRuntimeSettings, type RuntimeSettings } from '../../shared/contracts'
|
||||
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
|
||||
import i18n from './i18n'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ProjectRuntimeSelector rules', () => {
|
||||
it('allows fixed Runtime models and switching back to a profile-free reference', async () => {
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
const profileId = '00000000-0000-4000-8000-000000000011'
|
||||
const settings = {
|
||||
...defaultRuntimeSettings,
|
||||
defaultModelProfileId: profileId,
|
||||
modelProfiles: [{
|
||||
id: profileId, name: 'Model A', modelName: 'model-a',
|
||||
baseUrl: 'https://example.com/v1', protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key', apiKeyConfigured: true
|
||||
}]
|
||||
} as RuntimeSettings
|
||||
const onChange = vi.fn()
|
||||
const props = {
|
||||
ariaLabel: 'Project Runtime', label: 'Runtime', onChange,
|
||||
runtimeSettings: settings
|
||||
}
|
||||
const view = render(<ProjectRuntimeSelector {...props} selection={{ provider: 'opencode' }} />)
|
||||
const select = screen.getByRole('combobox')
|
||||
for (const provider of ['opencode', 'continue', 'deepseek-harness'] as const) {
|
||||
expect(screen.getByRole('option', {
|
||||
name: `${provider === 'opencode' ? 'OpenCode' : provider === 'continue' ? 'Continue' : 'DeepSeek Harness'} · Model A`
|
||||
})).toHaveValue(`${provider}:${profileId}`)
|
||||
fireEvent.change(select, { target: { value: `${provider}:${profileId}` } })
|
||||
expect(onChange).toHaveBeenLastCalledWith({ provider, profileId })
|
||||
}
|
||||
view.rerender(<ProjectRuntimeSelector {...props} selection={{ provider: 'opencode', profileId }} />)
|
||||
expect(screen.getByText('通过 OpenCode 运行,固定使用 Model A。')).toBeInTheDocument()
|
||||
fireEvent.change(select, { target: { value: 'opencode:default' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith({ provider: 'opencode' })
|
||||
fireEvent.change(select, { target: { value: 'model:default' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith({ provider: 'model' })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import {
|
||||
isAgentRuntimeModelProtocol,
|
||||
isDeepSeekHarnessModelProfile,
|
||||
type RuntimeSettings
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
agentRuntimeSelectionKey,
|
||||
getRuntimeSelectionForProvider,
|
||||
@@ -54,6 +58,15 @@ function runtimeSelectionDescription(
|
||||
: selection.provider === 'continue'
|
||||
? 'Continue'
|
||||
: 'DeepSeek Harness'
|
||||
if (selection.profileId) {
|
||||
const profile = settings.modelProfiles.find(
|
||||
(candidate) => candidate.id === selection.profileId
|
||||
)
|
||||
return t('channels.project.fixedRuntimeDescription', {
|
||||
runtime: runtimeLabel,
|
||||
name: profile?.name ?? t('channels.project.missingProfile')
|
||||
})
|
||||
}
|
||||
return t('channels.project.runtimeDescription', {
|
||||
runtime: runtimeLabel
|
||||
})
|
||||
@@ -108,9 +121,22 @@ export function ProjectRuntimeSelector({
|
||||
...(selectionMode === 'configured' ? [{ provider: 'auto' as const }] : []),
|
||||
{ provider: 'model' }
|
||||
]
|
||||
const fixedRuntimeSelections = selectionMode === 'configured'
|
||||
? runtimeProviders.flatMap((provider) =>
|
||||
directProfiles.filter((profile) =>
|
||||
provider === 'deepseek-harness'
|
||||
? isDeepSeekHarnessModelProfile(profile)
|
||||
: isAgentRuntimeModelProtocol(profile.protocol)
|
||||
).map((profile) => ({ provider, profileId: profile.id }))
|
||||
)
|
||||
: []
|
||||
const fixedRuntimeSelection = runtimeSelection.provider !== 'model' &&
|
||||
'profileId' in runtimeSelection && runtimeSelection.profileId ? runtimeSelection : undefined
|
||||
'profileId' in runtimeSelection && runtimeSelection.profileId &&
|
||||
!fixedRuntimeSelections.some((candidate) =>
|
||||
agentRuntimeSelectionKey(candidate) === agentRuntimeSelectionKey(runtimeSelection)
|
||||
) ? runtimeSelection : undefined
|
||||
const selections = [...inheritedSelections, ...directSelections, ...runtimeSelections,
|
||||
...fixedRuntimeSelections,
|
||||
...(fixedRuntimeSelection ? [fixedRuntimeSelection] : [])]
|
||||
const selectionByKey = new Map(
|
||||
selections.map((candidate) => [
|
||||
@@ -190,6 +216,19 @@ export function ProjectRuntimeSelector({
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{fixedRuntimeSelections.length > 0 && (
|
||||
<optgroup label={t('channels.project.fixedRuntimeModels')}>
|
||||
{fixedRuntimeSelections.map((candidate) => (
|
||||
<option
|
||||
key={agentRuntimeSelectionKey(candidate)}
|
||||
value={agentRuntimeSelectionKey(candidate)}
|
||||
>
|
||||
{candidate.provider === 'opencode' ? 'OpenCode' : candidate.provider === 'continue' ? 'Continue' : 'DeepSeek Harness'}
|
||||
{' · '}{runtimeSettings.modelProfiles.find((profile) => profile.id === candidate.profileId)?.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<small>
|
||||
{runtimeSelectionDescription(
|
||||
|
||||
@@ -131,7 +131,7 @@ export const app = {
|
||||
eyebrow: 'CUSTOM TASK',
|
||||
title: 'New custom task',
|
||||
description:
|
||||
'Create a task that runs on schedule and keeps recording results in a conversation.',
|
||||
'Send task instructions to an ordinary conversation on schedule. Replies stay in the conversation just like manually sent messages.',
|
||||
close: 'Close new custom task',
|
||||
fields: {
|
||||
name: 'Task name',
|
||||
@@ -145,9 +145,9 @@ export const app = {
|
||||
current: 'Current conversation',
|
||||
new: 'New conversation',
|
||||
currentHelp:
|
||||
'Associate the task with this conversation without renaming it or changing normal chat.',
|
||||
'Use this conversation\'s history, Runtime, work mode, and enabled capabilities when the message is sent.',
|
||||
newHelp:
|
||||
'Create a conversation for this task, using the task name as its initial title.',
|
||||
'Create an independent ordinary conversation with project defaults and the task name as its title.',
|
||||
currentUnavailable:
|
||||
'The current conversation cannot host this task. Choose a new conversation.'
|
||||
},
|
||||
@@ -163,6 +163,7 @@ export const app = {
|
||||
weekly: 'Weekly'
|
||||
},
|
||||
scope: {
|
||||
conversationSettings: 'Use the target conversation\'s Runtime, work mode, and enabled tools at send time. Adjust them in the conversation.',
|
||||
title: 'Execution scope',
|
||||
project: 'Project',
|
||||
runtime: 'Runtime',
|
||||
|
||||
@@ -46,6 +46,8 @@ export const integrations = {
|
||||
directDescription:
|
||||
'Process messages directly with {{name}} ({{modelName}}).',
|
||||
defaultDirect: 'Direct · Follow the global default model',
|
||||
fixedRuntimeModels: 'Agent Runtime · Fixed model',
|
||||
fixedRuntimeDescription: 'Run through {{runtime}} with the fixed model {{name}}.',
|
||||
automaticDescription:
|
||||
'Follow the global default Runtime setting.',
|
||||
runtimeDescription:
|
||||
|
||||
@@ -123,7 +123,7 @@ export const app = {
|
||||
customTask: {
|
||||
eyebrow: '定制任务',
|
||||
title: '新建定制任务',
|
||||
description: '创建一个按计划自动运行,并持续记录在会话中的任务。',
|
||||
description: '按计划向普通会话发送任务要求,回复和手动发送的消息一样保留在会话中。',
|
||||
close: '关闭新建定制任务',
|
||||
fields: {
|
||||
name: '任务名称',
|
||||
@@ -136,8 +136,8 @@ export const app = {
|
||||
destination: {
|
||||
current: '当前会话',
|
||||
new: '新建会话',
|
||||
currentHelp: '把任务关联到当前会话,不更改会话名称或普通聊天能力。',
|
||||
newHelp: '为任务创建一条新会话,默认使用任务名称作为标题。',
|
||||
currentHelp: '发送时使用当前会话的历史、Runtime、工作模式和已启用能力。',
|
||||
newHelp: '创建独立的普通会话,使用项目默认设置,以任务名称作为标题。',
|
||||
currentUnavailable: '当前会话不能关联此任务,请选择新建会话。'
|
||||
},
|
||||
mode: {
|
||||
@@ -151,6 +151,7 @@ export const app = {
|
||||
weekly: '每周'
|
||||
},
|
||||
scope: {
|
||||
conversationSettings: '发送时使用目标会话的 Runtime、工作模式和已启用工具,可在会话中调整。',
|
||||
title: '执行范围',
|
||||
project: '项目',
|
||||
runtime: 'Runtime',
|
||||
|
||||
@@ -40,6 +40,8 @@ export const integrations = {
|
||||
'直接使用 {{name}}({{modelName}})处理消息。',
|
||||
automaticDescription: '跟随全局 Runtime 默认设置',
|
||||
defaultDirect: '直连 · 跟随全局默认模型',
|
||||
fixedRuntimeModels: 'Agent Runtime · 固定模型',
|
||||
fixedRuntimeDescription: '通过 {{runtime}} 运行,固定使用 {{name}}。',
|
||||
runtimeDescription:
|
||||
'通过 {{runtime}} Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 {{runtime}} 配置。',
|
||||
defaultMode: '默认模式',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import {
|
||||
getDefaultRuntimeSelection,
|
||||
getRuntimeSelectionProfileId,
|
||||
repairChannelRuntimeSelection,
|
||||
getRuntimeSelectionForProvider
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
|
||||
@@ -18,6 +19,26 @@ function harnessSettings(
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness runtime selection', () => {
|
||||
it.each(['opencode', 'continue', 'deepseek-harness'] as const)(
|
||||
'preserves an explicit %s channel profile even when it equals the old default', (provider) => {
|
||||
const selection = { provider, profileId: harnessProfileId }
|
||||
const settings = {
|
||||
defaultModelProfileId: '00000000-0000-4000-8000-000000000072',
|
||||
modelProfiles: [{
|
||||
id: harnessProfileId,
|
||||
baseUrl: 'https://gateway.example/v1',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key' as const,
|
||||
apiKeyConfigured: true
|
||||
}],
|
||||
opencodeModelSource: { kind: 'default' as const },
|
||||
continueModelSource: { kind: 'platform' as const }
|
||||
}
|
||||
expect(repairChannelRuntimeSelection(selection, settings)).toEqual(selection)
|
||||
expect(repairChannelRuntimeSelection({ provider }, settings)).toEqual({ provider })
|
||||
}
|
||||
)
|
||||
|
||||
it('references the Runtime configuration without copying its profile', () => {
|
||||
const selection = getRuntimeSelectionForProvider(
|
||||
'deepseek-harness',
|
||||
|
||||
@@ -482,6 +482,7 @@ export const conversationSnapshotSchema = z
|
||||
projectId: assistantIdSchema.optional(),
|
||||
runtimeSelection: agentRuntimeSelectionSchema.optional(),
|
||||
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
|
||||
workMode: workModeSchema.optional(),
|
||||
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
||||
contextMetrics: conversationContextMetricsSchema.optional(),
|
||||
contextCompressionState:
|
||||
@@ -721,9 +722,9 @@ export const scheduleCreateSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ScheduleCreateInput = z.infer<typeof scheduleCreateSchema>
|
||||
export type ScheduleCreateInput = z.input<typeof scheduleCreateSchema>
|
||||
|
||||
export type AssistantSchedule = ScheduleCreateInput & {
|
||||
export type AssistantSchedule = z.output<typeof scheduleCreateSchema> & {
|
||||
id: string
|
||||
taskId: string
|
||||
conversationId: string
|
||||
|
||||
@@ -397,8 +397,13 @@ export type ConversationQueueUserInput = z.infer<
|
||||
|
||||
export type ConversationQueueDispatch = {
|
||||
item: ConversationQueueItem
|
||||
input: ConversationQueueUserInput
|
||||
}
|
||||
} & (
|
||||
| { scheduled?: false; input: ConversationQueueUserInput }
|
||||
| {
|
||||
scheduled: true
|
||||
input: Pick<ConversationQueueUserInput, 'conversationId' | 'projectId' | 'prompt'>
|
||||
}
|
||||
)
|
||||
|
||||
export const runtimeProviderSchema = z.enum([
|
||||
'auto',
|
||||
|
||||
@@ -182,7 +182,7 @@ export function repairChannelRuntimeSelection(
|
||||
selection.provider === 'opencode' ||
|
||||
selection.provider === 'continue'
|
||||
) {
|
||||
return { provider: selection.provider }
|
||||
return repairAgentRuntimeSelection(selection, settings)
|
||||
}
|
||||
if (selection.provider === 'deepseek-harness') {
|
||||
const repaired = repairAgentRuntimeSelection(selection, settings)
|
||||
|
||||
Reference in New Issue
Block a user