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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user