diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index b7231893..44186f17 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -845,13 +845,13 @@ DOCX tool 回归以 7 个公开工具、story 内容树、稳定 `nodeId`、segm #### live 类别 -##### `human-scheduled-task-create-update-live` +##### `human-scheduled-task-lifecycle-live` - 测试类型:`backend / live` -- What:真实模型根据自然语言为当前伙伴创建每日任务,读取确认后在下一轮修改时间并关闭,再次读取确认。 +- What:真实模型在同一伙伴的多个 run 中学习定时任务技能,完成任务创建、读取、修改、停用、删除并复读确认。 - Why:需要验证真实模型能发现并学习 external skill,正确选择 action,并遵循写后再读的工具设计。 -- How:Run 1 要求创建每天 09:00 的晨报并确认;Run 2 要求改为 10:00、关闭并确认。不等待真实调度触发。 -- Oracle:Run 1 出现 `learn_skill(scheduled-tasks)`、create 和 list;创建默认开启;Run 2 出现 update 和 list,关闭任务后最终数据库只在当前伙伴下存在目标任务,时间为 `10:00` 且 `enabled=false`。 +- How:Run 1 要求创建每天 09:00 的晨报并读取确认;Run 2 要求修改为 10:00、更新描述并关闭,再次读取确认;Run 3 要求删除任务并读取确认。不等待真实调度触发。 +- Oracle:Run 1 出现 `learn_skill(scheduled-tasks)`、create 和 list,创建默认开启;Run 2 出现 update 和 list,时间、描述和停用状态落库;Run 3 出现 delete 和 list,最终当前伙伴没有定时任务。不固定工具调用顺序,只验证这些动作和最终事实。 ##### `human-excel-read-weekly-review-live` diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index a8e4b8d9..2faec2ef 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -637,6 +637,22 @@ pnpm test:update:e2e - How:通过真实 UI 在 `任务描述` 编辑器选择 `sample-edit.docx`,从 IPC 读取任务的 `contentNodes` / `selectedReferences`,再立即执行并读取绑定伙伴的用户消息。 - Oracle:编辑器显示引用 token;任务保存后同时包含文本节点、引用节点和 `selectedReferences`;触发消息保留同一引用,对齐 `ST-FORM-001-AC4`、`ST-RUN-001-AC1`。 +#### `scheduled-task-ai-create` + +- 测试类型:`e2e` +- What:从定时任务视图进入 AI 创建,选择伙伴后切换到该伙伴聊天并预填任务设置引导语。 +- Why:AI 创建只负责准备当前伙伴的对话草稿,不应创建任务、发送消息或启动运行。 +- How:通过真实 UI 打开 AI 创建、选择伙伴并确认;读取当前聊天草稿、伙伴选中状态和消息记录,切换伙伴后再切回确认草稿仍然存在。 +- Oracle:切换到所选伙伴;草稿等于任务设置引导语;没有用户消息和运行状态变化,对齐 `ST-FORM-001-AC5`。 + +#### `scheduled-task-partner-deletion` + +- 测试类型:`e2e` +- What:删除绑定伙伴后,定时任务保留、清空绑定并停用,重新绑定后仍保持原停用状态。 +- Why:伙伴删除不能静默删除任务,也不能让失效任务继续执行。 +- How:通过真实 UI 创建绑定任务,删除绑定伙伴,从任务列表读取状态,再编辑任务绑定另一个伙伴并保存。 +- Oracle:删除伙伴后任务显示未绑定且开关禁用;重新绑定成功但启用状态仍为关闭,对齐 `ST-PARTNER-001-AC1`、`ST-SWITCH-001-AC3`。 + #### `scheduled-task-trigger-and-session-refresh` - 测试类型:`e2e / live` diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 13c16689..ba950dfd 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -299,7 +299,7 @@ export function registerHandlers(services: { return Number.isInteger(value) && value > 0 ? value : undefined })() let e2eGraphRecursionLimitUsesRemaining = e2eGraphRecursionLimitOnce ? 1 : 0 - let scheduledTaskService: ScheduledTaskService | undefined + const scheduledTaskServiceRef: { current?: ScheduledTaskService } = {} const chatRunService = new ChatRunService({ partnerStore, chatMessageLogStore, @@ -321,7 +321,7 @@ export function registerHandlers(services: { runResourceManager, browserUseService: browserEmbedService, webCrawlerService, - scheduledTaskService, + scheduledTaskService: () => scheduledTaskServiceRef.current, telemetryService, errorLogService, recordLlmIo: services.recordLlmIo, @@ -359,7 +359,7 @@ export function registerHandlers(services: { }) } - scheduledTaskService = new ScheduledTaskService({ + scheduledTaskServiceRef.current = new ScheduledTaskService({ taskStore: storageService.scheduledTask, partnerStore, getPartnerUiState: (partnerId) => workbenchUiStore.getPartnerState(partnerId), @@ -367,6 +367,8 @@ export function registerHandlers(services: { submitTask: (payload) => chatRunService.submit(payload), emitSessionListRefresh: emitSessionListRefreshToAll }) + const scheduledTaskService = scheduledTaskServiceRef.current + if (!scheduledTaskService) throw new Error('定时任务服务初始化失败') void scheduledTaskService.start() const emitSourceListRefresh = (sender: Electron.WebContents) => { @@ -3195,6 +3197,7 @@ export function registerHandlers(services: { await storageService.scheduledTask.clearPartner(id, Date.now()) await chatRunService.deletePartner(id) await browserEmbedService?.closeOwnerTabs(id) + emitSessionListRefreshToAll() return { success: true } } catch (e: unknown) { console.error('[CHAT-SESSION-DELETE] Error:', e) diff --git a/src/main/services/ai/ChatRunService.ts b/src/main/services/ai/ChatRunService.ts index e11f1ac6..83d77804 100644 --- a/src/main/services/ai/ChatRunService.ts +++ b/src/main/services/ai/ChatRunService.ts @@ -106,7 +106,9 @@ type ChatRunServiceDeps = { runResourceManager: AiRunResourceManager browserUseService?: BrowserEmbedService webCrawlerService?: WebCrawlerService - scheduledTaskService?: import('../scheduler/ScheduledTaskService').ScheduledTaskService + scheduledTaskService?: + | import('../scheduler/ScheduledTaskService').ScheduledTaskService + | (() => import('../scheduler/ScheduledTaskService').ScheduledTaskService | undefined) telemetryService?: TelemetryService errorLogService?: AppErrorLogService recordLlmIo?: (record: AiLlmIoRecord) => void @@ -455,7 +457,7 @@ export class ChatRunService { taskId: pending.taskId, interactionId: pending.interactionId, kind: 'question', - result: payload.answers as unknown as JsonObject, + result: JSON.parse(JSON.stringify(payload.answers)) as JsonObject, resolvedAt: Date.now() }) this.activeQuestions.delete(runId) @@ -679,6 +681,11 @@ export class ChatRunService { inputText: run.input }) + const scheduledTaskService = + typeof this.deps.scheduledTaskService === 'function' + ? this.deps.scheduledTaskService() + : this.deps.scheduledTaskService + try { const result = await AiRunService.getInstance().run( { @@ -718,7 +725,7 @@ export class ChatRunService { requestUserQuestion: async (request) => await this.requestUserQuestion(runId, request), browserUseService: this.deps.browserUseService, webCrawlerService: this.deps.webCrawlerService, - scheduledTaskService: this.deps.scheduledTaskService, + scheduledTaskService, humanRuntimeOverrides: this.deps.resolveHumanRuntimeOverrides?.(run) } ) diff --git a/src/main/services/ai/human/node/humanInteractionNode/index.ts b/src/main/services/ai/human/node/humanInteractionNode/index.ts index 31360f71..8f001ffc 100644 --- a/src/main/services/ai/human/node/humanInteractionNode/index.ts +++ b/src/main/services/ai/human/node/humanInteractionNode/index.ts @@ -5,6 +5,7 @@ import type { HumanRuntimeCache, HumanState, ToolCallResult } from '../../types' import { appendLogEntriesToCache } from '../../memory/conversationLogMessages' import { renderAskUserQuestionModelResult } from '../../shared/modelToolResultRenderers' import { returnToBrain } from '../utils/stateTransitions' +import { emitToolProcessCompleted, emitToolProcessStarted } from '../../../events/processEvents' export const createHumanInteractionNode = (input: { ctx: AiRunContext @@ -18,6 +19,14 @@ export const createHumanInteractionNode = (input: { if (questions.length < 1 || questions.length > 3) { throw new Error('ask_user_question requires 1 to 3 questions') } + emitToolProcessStarted({ + sendEvent: input.ctx.sendEvent, + runId: input.ctx.runId, + toolCallId: action.toolCallId, + toolName: 'ask_user_question', + args: action.args, + createdAt: Date.now() + }) const interactionId = randomUUID() const response = await input.ctx.requestUserQuestion({ taskId: state.task.taskId, @@ -26,6 +35,24 @@ export const createHumanInteractionNode = (input: { questions: questions as unknown as UserQuestion[] }) if (!response.accepted) throw new Error(response.error) + // The renderer may have been refreshed while the question was pending. Re-emit + // the stable tool activity so the resumed UI can reconstruct the question row. + emitToolProcessStarted({ + sendEvent: input.ctx.sendEvent, + runId: input.ctx.runId, + toolCallId: action.toolCallId, + toolName: 'ask_user_question', + args: action.args, + createdAt: Date.now() + }) + emitToolProcessCompleted({ + sendEvent: input.ctx.sendEvent, + runId: input.ctx.runId, + toolCallId: action.toolCallId, + toolName: 'ask_user_question', + ok: true, + completedAt: Date.now() + }) const result: ToolCallResult = { toolName: 'ask_user_question', ok: true, diff --git a/src/main/services/ai/human/shared/modelToolResultRenderers.ts b/src/main/services/ai/human/shared/modelToolResultRenderers.ts index 8082ca80..56f27400 100644 --- a/src/main/services/ai/human/shared/modelToolResultRenderers.ts +++ b/src/main/services/ai/human/shared/modelToolResultRenderers.ts @@ -72,7 +72,8 @@ export const renderScheduledTaskListModelResult: ModelResultRenderer = ({ data } ? `每周 ${weekdayLabels[weekday] || String(weekday)} ${readString(task.timeOfDay) || ''}` : `每天 ${readString(task.timeOfDay) || ''}` const state = readBoolean(task.enabled) === true ? '已启用' : '已停用' - return `${index + 1}. ${readString(task.id) || ''}|${readString(task.name) || ''}|${schedule}|${state}` + const description = (readString(task.description) || '').replace(/\r?\n/gu, '\n ') + return `${index + 1}. ${readString(task.name) || ''}|${schedule}|${state}|id=${readString(task.id) || ''}\n 描述:${description}` }) return ['当前伙伴的定时任务:', ...lines].join('\n') } diff --git a/src/main/services/ai/human/tools/agentControl/toolSpecs.ts b/src/main/services/ai/human/tools/agentControl/toolSpecs.ts index 6aa726c6..863b3767 100644 --- a/src/main/services/ai/human/tools/agentControl/toolSpecs.ts +++ b/src/main/services/ai/human/tools/agentControl/toolSpecs.ts @@ -26,7 +26,7 @@ export const ASK_USER_QUESTION_TOOL_SPEC: HumanToolSpec< promptSpec: { name: 'ask_user_question', description: - '在推进任务时,若遇到无法从已有上下文、资料或工具结果可靠判断,且不同理解可能改变用户意图、执行方案或最终结果的关键歧义,请向用户确认。对于可以自行判断或通过读取资料确定的信息不要询问。' + '在推进任务时,如果碰到无法从已有上下文、资料或工具结果可靠判断的疑惑、歧义或可能违背用户意图的地方,向用户询问并沟通确认。对于可以自行判断或通过读取资料确定的信息不要询问;只有不同理解可能改变用户意图、执行方案或最终结果时才提问。' }, resolveExecutionMeta: createStaticExecutionMetaResolver({ mutability: 'read_only', diff --git a/src/main/services/ai/human/tools/scheduledTasks/toolSchemas.ts b/src/main/services/ai/human/tools/scheduledTasks/toolSchemas.ts new file mode 100644 index 00000000..f17417e6 --- /dev/null +++ b/src/main/services/ai/human/tools/scheduledTasks/toolSchemas.ts @@ -0,0 +1,110 @@ +import { z } from 'zod/v4' + +export const SCHEDULED_TASK_LIST_SCHEMA = z.strictObject({}) + +const scheduledTaskCreateSchema = z + .object({ + action: z.literal('create').describe('创建任务'), + name: z.string().trim().min(1).describe('定时任务名称'), + description: z.string().trim().min(1).describe('任务描述'), + scheduleType: z.enum(['daily', 'weekly']).describe('执行周期,daily 表示每天,weekly 表示每周'), + timeOfDay: z + .string() + .regex(/^([01]\d|2[0-3]):[0-5]\d$/) + .describe('执行时间,格式 HH:mm'), + weekday: z + .number() + .int() + .min(0) + .max(6) + .nullable() + .optional() + .describe('每周执行的星期,0 为周日,6 为周六') + }) + .strict() + +const scheduledTaskUpdateSchema = z + .object({ + action: z.literal('update').describe('修改任务'), + id: z.string().trim().min(1).describe('定时任务 ID'), + name: z.string().trim().min(1).optional().describe('定时任务名称'), + description: z.string().trim().min(1).optional().describe('任务描述'), + scheduleType: z + .enum(['daily', 'weekly']) + .optional() + .describe('执行周期,daily 表示每天,weekly 表示每周'), + timeOfDay: z + .string() + .regex(/^([01]\d|2[0-3]):[0-5]\d$/) + .optional() + .describe('执行时间,格式 HH:mm'), + weekday: z + .number() + .int() + .min(0) + .max(6) + .nullable() + .optional() + .describe('每周执行的星期,0 为周日,6 为周六'), + enabled: z.boolean().optional().describe('是否启用任务') + }) + .strict() + +const scheduledTaskDeleteSchema = z + .object({ + action: z.literal('delete').describe('删除任务'), + id: z.string().trim().min(1).describe('定时任务 ID') + }) + .strict() + +export const SCHEDULED_TASK_ACTION_SCHEMA = z + .discriminatedUnion('action', [ + scheduledTaskCreateSchema, + scheduledTaskUpdateSchema, + scheduledTaskDeleteSchema + ]) + .superRefine((input, context) => { + if (input.action === 'create') { + if (input.scheduleType === 'weekly' && input.weekday == null) { + context.addIssue({ + code: 'custom', + path: ['weekday'], + message: '每周任务必须提供 weekday' + }) + } + if (input.scheduleType === 'daily' && input.weekday != null) { + context.addIssue({ + code: 'custom', + path: ['weekday'], + message: '每天任务不能提供 weekday' + }) + } + return + } + + if (input.action === 'update') { + const hasChange = + input.name !== undefined || + input.description !== undefined || + input.scheduleType !== undefined || + input.timeOfDay !== undefined || + input.weekday !== undefined || + input.enabled !== undefined + if (!hasChange) { + context.addIssue({ + code: 'custom', + message: '修改任务时至少提供一个要修改的字段' + }) + } + if (input.scheduleType === 'daily' && input.weekday != null) { + context.addIssue({ + code: 'custom', + path: ['weekday'], + message: '改为每天执行时 weekday 必须为空' + }) + } + } + }) + +export type ScheduledTaskListInput = z.infer +export type ScheduledTaskActionInput = z.infer diff --git a/src/main/services/ai/human/tools/scheduledTasks/toolSpecs.ts b/src/main/services/ai/human/tools/scheduledTasks/toolSpecs.ts index 3a9a4c5c..48a329c4 100644 --- a/src/main/services/ai/human/tools/scheduledTasks/toolSpecs.ts +++ b/src/main/services/ai/human/tools/scheduledTasks/toolSpecs.ts @@ -1,4 +1,3 @@ -import { z } from 'zod/v4' import type { HumanToolSpec } from '../types' import { createStaticExecutionMetaResolver } from '../utils/toolSpecUtils' import { @@ -6,54 +5,22 @@ import { renderScheduledTaskModelError, renderScheduledTaskMutationModelResult } from '../../shared/modelToolResultRenderers' - -const scheduleFields = { - name: z.string().min(1).describe('任务名称。'), - description: z.string().min(1).describe('任务描述。'), - scheduleType: z.enum(['daily', 'weekly']).describe('执行周期:daily 每天,weekly 每周。'), - timeOfDay: z.string().min(1).describe('执行时间,格式 HH:mm。') -} - -const weekday = z.number().int().min(0).max(6).describe('每周执行的星期,0 为周日,6 为周六。') - -const scheduledTaskActionSchema = z.union([ - z.strictObject({ - action: z.literal('create').describe('创建任务。'), - ...scheduleFields, - scheduleType: z.literal('daily').describe('每天执行。'), - weekday: z.null().optional().describe('每天执行时不需要填写。') - }), - z.strictObject({ - action: z.literal('create').describe('创建任务。'), - ...scheduleFields, - scheduleType: z.literal('weekly').describe('每周执行。'), - weekday - }), - z.strictObject({ - action: z.literal('update').describe('修改任务。'), - id: z.string().min(1).describe('要修改的任务 ID。'), - name: scheduleFields.name.optional(), - description: scheduleFields.description.optional(), - scheduleType: scheduleFields.scheduleType.optional(), - timeOfDay: scheduleFields.timeOfDay.optional(), - weekday: weekday.nullable().optional().describe('每周执行的星期;改为每天时传 null。'), - enabled: z.boolean().optional().describe('是否启用任务;未提供时保持原状态。') - }), - z.strictObject({ - action: z.literal('delete').describe('删除任务。'), - id: z.string().min(1).describe('要删除的任务 ID。') - }) -]) +import { + SCHEDULED_TASK_ACTION_SCHEMA, + SCHEDULED_TASK_LIST_SCHEMA, + type ScheduledTaskActionInput, + type ScheduledTaskListInput +} from './toolSchemas' export const SCHEDULED_TASK_LIST_SPEC: HumanToolSpec< - Record, + ScheduledTaskListInput, 'scheduled_task_list', 'scheduled-tasks' > = { name: 'scheduled_task_list', familyId: 'scheduled-tasks', skillIds: ['scheduled-tasks'], - inputSchema: z.object({}), + inputSchema: SCHEDULED_TASK_LIST_SCHEMA, promptSpec: { name: 'scheduled_task_list', description: '读取当前伙伴的定时任务。' @@ -69,14 +36,14 @@ export const SCHEDULED_TASK_LIST_SPEC: HumanToolSpec< } export const SCHEDULED_TASK_ACTION_SPEC: HumanToolSpec< - z.infer, + ScheduledTaskActionInput, 'scheduled_task_action', 'scheduled-tasks' > = { name: 'scheduled_task_action', familyId: 'scheduled-tasks', skillIds: ['scheduled-tasks'], - inputSchema: scheduledTaskActionSchema, + inputSchema: SCHEDULED_TASK_ACTION_SCHEMA, promptSpec: { name: 'scheduled_task_action', description: '创建、修改或删除当前伙伴的定时任务。' @@ -96,4 +63,4 @@ export const SCHEDULED_TASK_TOOL_SPECS = [ SCHEDULED_TASK_ACTION_SPEC ] as const -export type ScheduledTaskActionInput = z.infer +export type { ScheduledTaskActionInput } from './toolSchemas' diff --git a/src/renderer/src/components/workbench/AssistantTurnCard.tsx b/src/renderer/src/components/workbench/AssistantTurnCard.tsx index 8e9a40a9..c57fad3e 100644 --- a/src/renderer/src/components/workbench/AssistantTurnCard.tsx +++ b/src/renderer/src/components/workbench/AssistantTurnCard.tsx @@ -39,6 +39,7 @@ import { buildAssistantExecutionSummaryText, resolveAssistantExecutionIcon, resolveAssistantExecutionStatus, + resolveQuestionInteraction, shouldShowAssistantApplyTargets, isAssistantActivityToolGroup } from './utils' @@ -381,6 +382,7 @@ const AssistantActivityItemView: React.FC<{ onPreviewImage?: (image: ImageLightboxImage) => void }> = ({ item, review, onPreviewImage }) => { const failed = item.status === 'failed' || item.status === 'partial_failed' + const interaction = resolveQuestionInteraction(item) const toolText = getItemText(item) const readImagePaths = item.toolName === 'read_image' ? readImagePathsFromInput(item.input) : [] @@ -393,7 +395,7 @@ const AssistantActivityItemView: React.FC<{ return (
{renderSegments(toolText)} - {item.interaction ? : null} + {interaction ? : null} {thumbs}
) diff --git a/src/renderer/src/components/workbench/messageLogProjection.ts b/src/renderer/src/components/workbench/messageLogProjection.ts index 6c0276c3..681a42c0 100644 --- a/src/renderer/src/components/workbench/messageLogProjection.ts +++ b/src/renderer/src/components/workbench/messageLogProjection.ts @@ -89,16 +89,17 @@ export const projectProcessGroups = (input: { } if (entry.kind === 'assistant') { - if (!thinkingOpen) continue - const text = normalizeThoughtText(entry.content) - if (text) { - const thoughtCreatedAt = resolveCreatedAt(record) - thoughts.push({ - id: `${input.runId}:thought:seq:${record.seq}`, - text, - createdAt: thoughtCreatedAt, - sourceEventType: 'assistant_process_thought' - }) + if (thinkingOpen) { + const text = normalizeThoughtText(entry.content) + if (text) { + const thoughtCreatedAt = resolveCreatedAt(record) + thoughts.push({ + id: `${input.runId}:thought:seq:${record.seq}`, + text, + createdAt: thoughtCreatedAt, + sourceEventType: 'assistant_process_thought' + }) + } } for (const call of entry.toolCalls) { toolArgsById.set(call.id, call.args) diff --git a/src/renderer/src/components/workbench/utils.ts b/src/renderer/src/components/workbench/utils.ts index b108c519..2955fe30 100644 --- a/src/renderer/src/components/workbench/utils.ts +++ b/src/renderer/src/components/workbench/utils.ts @@ -574,7 +574,8 @@ export const createDisplayProcessItem = (input: { input.existing?.input || (input.display.kind === 'tool' ? input.display.input : undefined), targetLabel: input.existing?.targetLabel || - (input.display.kind === 'tool' ? readToolFileLabel(input.display.input) : undefined) + (input.display.kind === 'tool' ? readToolFileLabel(input.display.input) : undefined), + interaction: input.existing?.interaction } } @@ -612,6 +613,17 @@ const createFileRefFromObject = ( export const getAssistantToolNarrative = (toolName?: string): string => plainText(getToolSummaryText(getToolUiKind(normalizeToolName(String(toolName || ''))), 'running')) +export const resolveQuestionInteraction = ( + item: AssistantProcessGroupItem +): AssistantProcessGroupItem['interaction'] | undefined => { + if (item.interaction) return item.interaction + if (item.toolName !== 'ask_user_question' || !item.input) return undefined + const input = item.input as { + questions?: NonNullable['questions'] + } + return Array.isArray(input.questions) ? { questions: input.questions } : undefined +} + export const buildAssistantProcessText = (event: AiRunEvent): string | null => { if (event.type === 'assistant_process_group_started') { return plainText(getDisplayGroupText(event.display, 'running')) @@ -889,6 +901,37 @@ export const reduceAssistantProcessGroups = ( current: AssistantProcessGroup[], event: AiRunEvent ): AssistantProcessGroup[] => { + if (event.type === 'human_interaction_required') { + const interaction = event.interaction + if (interaction.kind !== 'question') return current + const display: AssistantProcessDisplay = { + kind: 'tool', + toolName: 'ask_user_question', + input: { questions: interaction.questions } + } + const groupId = `${event.runId}:tool:${interaction.toolCallId}` + const next = upsertProcessItem(current, { + groupId, + itemId: interaction.toolCallId, + display, + status: 'running', + createdAt: event.createdAt, + sourceEventType: event.type + }) + return next.map((group) => + group.id !== groupId + ? group + : { + ...group, + items: group.items.map((item) => + item.id === interaction.toolCallId + ? { ...item, interaction: { questions: interaction.questions } } + : item + ) + } + ) + } + if (event.type === 'assistant_process_thought') { return withoutThinkingGroups(current) } @@ -980,6 +1023,43 @@ export const reduceAssistantProcessGroups = ( return current } +export const applyQuestionInteractionResult = ( + current: AssistantProcessGroup[], + event: AiRunEvent +): AssistantProcessGroup[] => { + if (event.type !== 'human_interaction_resolved' || event.kind !== 'question') return current + const rawAnswers = event.result.answers + if (!Array.isArray(rawAnswers)) return current + const answers = rawAnswers.filter((answer): answer is { id: string; selected: string[]; custom?: string } => { + if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return false + const candidate = answer as { id?: unknown; selected?: unknown; custom?: unknown } + return typeof candidate.id === 'string' && + Array.isArray(candidate.selected) && + candidate.selected.every((value) => typeof value === 'string') && + (candidate.custom === undefined || typeof candidate.custom === 'string') + }) + if (answers.length === 0) return current + + let targetFound = false + const next = [...current].reverse().flatMap((group) => { + const items = [...group.items].reverse().map((item) => { + if ( + targetFound || + item.toolName !== 'ask_user_question' || + !resolveQuestionInteraction(item) || + resolveQuestionInteraction(item)?.answers + ) { + return item + } + targetFound = true + const interaction = resolveQuestionInteraction(item) + return interaction ? { ...item, interaction: { ...interaction, answers } } : item + }).reverse() + return [{ ...group, items }] + }).reverse() + return targetFound ? next : current +} + export const shouldShowAssistantApplyTargets = (input: { runFinished: boolean applyTargetCount: number diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index 9773acee..dc5f584e 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -128,6 +128,7 @@ import { extractReadableFilesFromToolOutput, formatRelativeTime, getAttachmentTypeLabel, + applyQuestionInteractionResult, reduceAssistantProcessGroups, reduceAssistantProcessThoughts } from '../components/workbench/utils' @@ -345,7 +346,10 @@ const reduceAssistantTurnByRunEvent = ( let next: AssistantTurnViewModel = { ...turn, processThoughts: reduceAssistantProcessThoughts(turn.processThoughts, event), - processGroups: reduceAssistantProcessGroups(turn.processGroups, event) + processGroups: applyQuestionInteractionResult( + reduceAssistantProcessGroups(turn.processGroups, event), + event + ) } if (event.type === 'tool_call_result') { @@ -653,11 +657,23 @@ const buildAssistantTurnFromRunSnapshot = ( const approvalReviews = projectApprovalReviews({ records: snapshot.messageLogRecords || [] }) + const pendingInteraction = snapshot.pendingInteraction || snapshot.run.pendingInteraction + const processGroups = + pendingInteraction?.kind === 'question' + ? reduceAssistantProcessGroups(projected.processGroups, { + type: 'human_interaction_required', + runId: snapshot.run.id, + taskId: snapshot.run.id, + interaction: pendingInteraction, + interactionId: pendingInteraction.interactionId, + createdAt: snapshot.run.updatedAt || snapshot.run.createdAt + }) + : projected.processGroups const base: AssistantTurnViewModel = { ...createAssistantTurn(snapshot.run.assistantMessageId), runId: snapshot.run.id, processThoughts: projected.processThoughts, - processGroups: projected.processGroups, + processGroups, isBrainThinking: projected.isBrainThinking, waitingApproval: Boolean(snapshot.run.pendingApproval || snapshot.run.pendingInteraction), pendingApprovalItems: snapshot.run.pendingApproval?.items || [], diff --git a/tests/case/ai/human/live/human-scheduled-task-create-update-live.test.ts b/tests/case/ai/human/live/human-scheduled-task-create-update-live.test.ts index 17613322..968ed129 100644 --- a/tests/case/ai/human/live/human-scheduled-task-create-update-live.test.ts +++ b/tests/case/ai/human/live/human-scheduled-task-create-update-live.test.ts @@ -8,14 +8,15 @@ import { collectStartedToolInputs, filterAiRunEventsByType } from '../testUtils' const PARTNER_ID = 'live-scheduled-task-partner' describe('ai human live scheduled task', () => { - it('human-scheduled-task-create-update-live', async () => { + it('human-scheduled-task-lifecycle-live', async () => { await runBackendCase({ - caseId: 'human-scheduled-task-create-update-live', + caseId: 'human-scheduled-task-lifecycle-live', harnessMode: 'live', domain: 'ai', suite: 'human', groups: ['live', 'scheduled-task'], - description: '验证真实模型学习定时任务技能,在当前伙伴创建、读取、修改并复读确认。', + description: + '验证真实模型在多个 run 中学习定时任务技能并完成创建、读取、修改、删除生命周期。', fixtures: [], runMode: 'single', runtimeModelName: loadLiveLlmConfigFromEnv().model_name, @@ -50,6 +51,7 @@ describe('ai human live scheduled task', () => { }) const run1Events = runtime.eventCollector.snapshot() const created = (await runtime.storageService.scheduledTask.listByPartner(PARTNER_ID))[0] + const createdEnabled = created?.enabled === true const run2 = await createHumanTestRunner(runtime, { partnerId: PARTNER_ID, @@ -57,44 +59,88 @@ describe('ai human live scheduled task', () => { }).run({ taskId: `${runtime.taskId}-update`, userInput: - '把刚才的「每日晨报」改到每天 10 点,并关闭它。修改后请再次读取当前定时任务确认结果。' + '把刚才的「每日晨报」改到每天 10 点,任务描述改为「整理当天的重要事项并标记风险」,并关闭它。修改后请再次读取当前定时任务确认结果。' }) const allEvents = runtime.eventCollector.snapshot() const run2Events = allEvents.slice(run1Events.length) - const finalTask = created + const updated = created ? await runtime.storageService.scheduledTask.getById(created.id) : null + + const run3 = await createHumanTestRunner(runtime, { + partnerId: PARTNER_ID, + scheduledTaskService: service + }).run({ + taskId: `${runtime.taskId}-delete`, + userInput: '请删除刚才的「每日晨报」定时任务,然后读取当前伙伴的定时任务确认它已经删除。' + }) + const finalEvents = runtime.eventCollector.snapshot() + const run3Events = finalEvents.slice(allEvents.length) + const remainingTasks = await runtime.storageService.scheduledTask.listByPartner(PARTNER_ID) service.stop() - return { run1, run2, run1Events, run2Events, finalTask } + return { + run1, + run2, + run3, + run1Events, + run2Events, + run3Events, + createdEnabled, + created, + updated, + remainingTasks + } }, assert: async (result, runtime) => { const run1Names = filterAiRunEventsByType(result.run1Events, 'tool_call_started').map( (event) => event.toolName ) + const run1Actions = collectStartedToolInputs(result.run1Events, 'scheduled_task_action') const run2Actions = collectStartedToolInputs(result.run2Events, 'scheduled_task_action') const run2Names = filterAiRunEventsByType(result.run2Events, 'tool_call_started').map( (event) => event.toolName ) + const run3Actions = collectStartedToolInputs(result.run3Events, 'scheduled_task_action') + const run3Names = filterAiRunEventsByType(result.run3Events, 'tool_call_started').map( + (event) => event.toolName + ) runtime.assertThat( result.run1.status === 'completed' && result.run2.status === 'completed' && + result.run3.status === 'completed' && run1Names.includes('learn_skill') && run1Names.includes('scheduled_task_action') && run1Names.includes('scheduled_task_list') && + run1Actions.some((input) => input.action === 'create') && + result.createdEnabled && + run2Names.includes('scheduled_task_action') && run2Names.includes('scheduled_task_list') && run2Actions.some((input) => input.action === 'update' && input.enabled === false) && - result.finalTask?.partnerId === PARTNER_ID && - result.finalTask.timeOfDay === '10:00' && - result.finalTask.enabled === false, + result.updated?.partnerId === PARTNER_ID && + result.updated.timeOfDay === '10:00' && + result.updated.enabled === false && + run3Names.includes('scheduled_task_action') && + run3Names.includes('scheduled_task_list') && + run3Actions.some((input) => input.action === 'delete') && + result.remainingTasks.length === 0, { - name: 'human-scheduled-task-create-update-live', - message: '真实模型应学习技能后创建并复读,再修改、停用并复读确认当前伙伴任务。', + name: 'human-scheduled-task-lifecycle-live', + message: '真实模型应跨多个 run 学习技能并完成当前伙伴任务的创建、读取、修改和删除。', data: { run1Names, run2Names, + run3Names, + run1Actions, run2Actions, - finalTask: result.finalTask, - deliveries: [result.run1.deliveryMarkdown, result.run2.deliveryMarkdown] + run3Actions, + created: result.created, + updated: result.updated, + remainingTasks: result.remainingTasks, + deliveries: [ + result.run1.deliveryMarkdown, + result.run2.deliveryMarkdown, + result.run3.deliveryMarkdown + ] } } ) diff --git a/tests/case/ai/tool/scheduled-task/scheduled-task-tool-contract.test.ts b/tests/case/ai/tool/scheduled-task/scheduled-task-tool-contract.test.ts index 3a0b7121..5589e273 100644 --- a/tests/case/ai/tool/scheduled-task/scheduled-task-tool-contract.test.ts +++ b/tests/case/ai/tool/scheduled-task/scheduled-task-tool-contract.test.ts @@ -68,6 +68,14 @@ describe('ai tool scheduled-task backend', () => { enabled: false }) const stored = await runtime.storageService.scheduledTask.getById(created.id) + const deleted = await harnessA.invoke<{ id: string }>('scheduled_task_action', { + action: 'delete', + id: created.id + }) + const listAfterDelete = await harnessA.invoke<{ tasks: unknown[] }>( + 'scheduled_task_list', + {} + ) const resultEvents = runtime.eventCollector .snapshot() .filter((event) => event.type === 'tool_call_result') @@ -80,6 +88,8 @@ describe('ai tool scheduled-task backend', () => { crossPartnerUpdate, updated, stored, + deleted, + listAfterDelete, resultEvents } }, @@ -102,7 +112,9 @@ describe('ai tool scheduled-task backend', () => { result.crossPartnerUpdate.error === '定时任务不存在' && result.updated.id === result.created.id && result.stored?.timeOfDay === '10:00' && - result.stored.enabled === false, + result.stored.enabled === false && + result.deleted.id === result.created.id && + result.listAfterDelete.tasks.length === 0, { name: 'scheduled-task-tool-contract', message: '工具应只返回当前伙伴的最小任务字段,并原子更新配置和启用状态。', diff --git a/tests/case/unit/ai/runtime-skill-contract.test.ts b/tests/case/unit/ai/runtime-skill-contract.test.ts index ba2bf01e..1a79d281 100644 --- a/tests/case/unit/ai/runtime-skill-contract.test.ts +++ b/tests/case/unit/ai/runtime-skill-contract.test.ts @@ -60,6 +60,34 @@ describe('ai unit runtime skill contract', () => { timeOfDay: '09:00' }) expect(create.success).toBe(true) + expect( + SCHEDULED_TASK_ACTION_SPEC.inputSchema.safeParse({ + action: 'create', + name: '晨报', + description: '整理今天的安排', + scheduleType: 'daily', + timeOfDay: '9:00' + }).success + ).toBe(false) + expect( + SCHEDULED_TASK_ACTION_SPEC.inputSchema.safeParse({ + action: 'create', + name: '晨报', + description: '整理今天的安排', + scheduleType: 'weekly', + timeOfDay: '09:00' + }).success + ).toBe(false) + expect( + SCHEDULED_TASK_ACTION_SPEC.inputSchema.safeParse({ + action: 'create', + name: '晨报', + description: '整理今天的安排', + scheduleType: 'weekly', + timeOfDay: '09:00', + weekday: 1 + }).success + ).toBe(true) const createWithUnknownField = SCHEDULED_TASK_ACTION_SPEC.inputSchema.safeParse({ action: 'create', name: '晨报', @@ -69,6 +97,13 @@ describe('ai unit runtime skill contract', () => { enabled: false }) expect(createWithUnknownField.success).toBe(false) + expect( + SCHEDULED_TASK_ACTION_SPEC.inputSchema.safeParse({ + action: 'update', + id: 'task-1' + }).success + ).toBe(false) + expect(SCHEDULED_TASK_LIST_SPEC.inputSchema.safeParse({ unexpected: true }).success).toBe(false) expect( SCHEDULED_TASK_ACTION_SPEC.inputSchema.safeParse({ action: 'delete', id: 'task-1' }).success ).toBe(true) diff --git a/tests/case/unit/ai/scheduled-task-tool-result.test.ts b/tests/case/unit/ai/scheduled-task-tool-result.test.ts index 92338fce..ffb99d0e 100644 --- a/tests/case/unit/ai/scheduled-task-tool-result.test.ts +++ b/tests/case/unit/ai/scheduled-task-tool-result.test.ts @@ -25,7 +25,7 @@ describe('scheduled task model result renderer', () => { ] } }) - ).toBe('当前伙伴的定时任务:\n1. task-1|每日晨报|每天 09:00|已启用') + ).toBe('当前伙伴的定时任务:\n1. 每日晨报|每天 09:00|已启用|id=task-1\n 描述:整理事项') expect( renderScheduledTaskListModelResult({ toolName: 'scheduled_task_list', diff --git a/tests/case/unit/workbench/message-log-projection.test.ts b/tests/case/unit/workbench/message-log-projection.test.ts index 7c437283..770a87f1 100644 --- a/tests/case/unit/workbench/message-log-projection.test.ts +++ b/tests/case/unit/workbench/message-log-projection.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' +import { + applyQuestionInteractionResult, + reduceAssistantProcessGroups +} from '../../../../src/renderer/src/components/workbench/utils' import { projectProcessGroups } from '../../../../src/renderer/src/components/workbench/messageLogProjection' -import type { ChatMessageLogRecord } from '../../../../src/shared/types' +import type { AiRunEvent, ChatMessageLogRecord } from '../../../../src/shared/types' describe('message log question projection', () => { it('projects questions from the tool call and all answers from the resolved annotation', () => { @@ -112,4 +116,97 @@ describe('message log question projection', () => { { id: 'format', selected: ['brief'] } ]) }) + + it('attaches answers to the live activity when the resolved event arrives', () => { + const runId = 'run-question-live' + const requiredEvent: AiRunEvent = { + type: 'human_interaction_required', + runId, + taskId: runId, + interactionId: 'interaction-1', + interaction: { + kind: 'question', + interactionId: 'interaction-1', + toolCallId: 'question-call', + questions: [{ + id: 'style', + question: '选择风格', + options: [{ id: 'minimal', label: '极简' }] + }] + }, + createdAt: 1 + } + const groups = reduceAssistantProcessGroups([], requiredEvent) + const event: AiRunEvent = { + type: 'human_interaction_resolved', + runId, + taskId: runId, + interactionId: 'interaction-1', + kind: 'question', + result: { answers: [{ id: 'style', selected: [], custom: '深绿色和白色' }] }, + resolvedAt: 3 + } + const updated = applyQuestionInteractionResult(groups, event) + const item = updated.flatMap((group) => group.items)[0] + expect(item?.interaction?.answers).toEqual([ + { id: 'style', selected: [], custom: '深绿色和白色' } + ]) + }) + + it('keeps answers when the tool activity is re-emitted after resolution', () => { + const runId = 'run-question-reemit' + const requiredEvent: AiRunEvent = { + type: 'human_interaction_required', + runId, + taskId: runId, + interactionId: 'interaction-1', + interaction: { + kind: 'question', + interactionId: 'interaction-1', + toolCallId: 'question-call', + questions: [{ + id: 'style', + question: '选择风格', + options: [{ id: 'minimal', label: '极简' }] + }] + }, + createdAt: 1 + } + let groups = reduceAssistantProcessGroups([], requiredEvent) + groups = applyQuestionInteractionResult(groups, { + type: 'human_interaction_resolved', + runId, + taskId: runId, + interactionId: 'interaction-1', + kind: 'question', + result: { answers: [{ id: 'style', selected: [], custom: '深绿色和白色' }] }, + resolvedAt: 2 + }) + + const display = { + kind: 'tool' as const, + toolName: 'ask_user_question', + input: { questions: requiredEvent.interaction.questions } + } + groups = reduceAssistantProcessGroups(groups, { + type: 'assistant_process_item_started', + runId, + groupId: `${runId}:tool:question-call`, + itemId: 'question-call', + display, + createdAt: 3 + }) + groups = reduceAssistantProcessGroups(groups, { + type: 'assistant_process_item_completed', + runId, + groupId: `${runId}:tool:question-call`, + itemId: 'question-call', + completedAt: 4 + }) + + const item = groups.flatMap((group) => group.items)[0] + expect(item?.interaction?.answers).toEqual([ + { id: 'style', selected: [], custom: '深绿色和白色' } + ]) + }) }) diff --git a/tests/case/workbench/run/blocker/ai-question-flow.e2e.test.ts b/tests/case/workbench/run/blocker/ai-question-flow.e2e.test.ts index 0b0f1054..ba419662 100644 --- a/tests/case/workbench/run/blocker/ai-question-flow.e2e.test.ts +++ b/tests/case/workbench/run/blocker/ai-question-flow.e2e.test.ts @@ -6,9 +6,11 @@ import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' import { WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS, collectWorkbenchAiRunEvents, + getWorkbenchPrefixLocator, selectWorkbenchExecutionPermission, setupWorkbenchWithTempWorkspace, sendWorkbenchMessage, + waitForLatestAssistantProcess, waitForWorkbenchAiEvent, workbenchSelectors } from '../../../../harness/e2e/workbench' @@ -46,6 +48,13 @@ describe('workbench AI question e2e', () => { '# Brief\n\n主题:待定\n\n视觉风格:未说明\n', 'utf8' ) + for (const entry of await fs.readdir(setup.workspace.rootPath)) { + if (entry === 'brief.md') continue + await fs.rm(path.join(setup.workspace.rootPath, entry), { + recursive: true, + force: true + }) + } // This case isolates AI clarification. Full access auto-approves // ordinary tools, while ask_user_question still waits for the user. await selectWorkbenchExecutionPermission(page, '完全访问') @@ -56,7 +65,7 @@ describe('workbench AI question e2e', () => { const required = await waitForWorkbenchAiEvent(runtime, { name: 'ai_question_required', - timeoutMs: 180_000, + timeoutMs: WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS, matches: isQuestionRequired }) if (!isQuestionRequired(required)) throw new Error('AI question was not requested') @@ -98,7 +107,6 @@ describe('workbench AI question e2e', () => { ) const mutatingTools = new Set([ 'text_file_edit', - 'terminal_run', 'docx_content_edit', 'docx_format', 'docx_table_edit', @@ -113,7 +121,7 @@ describe('workbench AI question e2e', () => { await answerQuestionCard(questions) const seenInteractionIds = new Set([required.interactionId]) - const completionDeadline = Date.now() + 180_000 + const completionDeadline = Date.now() + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS for (;;) { const events = (await collectWorkbenchAiRunEvents(runtime)).filter( (event) => event.runId === required.runId @@ -133,9 +141,16 @@ describe('workbench AI question e2e', () => { await new Promise((resolve) => setTimeout(resolve, 500)) } - const process = page.getByTestId(workbenchSelectors.assistantProcessPrefix).last() - await process.click() - const processText = (await process.textContent()) || '' + const process = await waitForLatestAssistantProcess(page) + await process.locator('summary').click() + const processLocators = getWorkbenchPrefixLocator( + page, + workbenchSelectors.assistantProcessPrefix + ) + for (const activity of await processLocators.all()) { + await activity.locator('summary').click().catch(() => undefined) + } + const processText = (await processLocators.allTextContents()).join('\n') const events = (await collectWorkbenchAiRunEvents(runtime)).filter( (event) => event.runId === required.runId ) diff --git a/tests/case/workbench/scheduled-task/scheduled-task-ai-create.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-ai-create.e2e.test.ts index 3e8164fc..96d0d305 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-ai-create.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-ai-create.e2e.test.ts @@ -9,6 +9,7 @@ import { openScheduledTasksView, scheduledTaskSelectors, setupWorkbenchWithTempWorkspace, + selectVisibleWorkbenchOption, workbenchSelectors } from '../../../harness/e2e/workbench' @@ -27,6 +28,7 @@ describe('workbench scheduled-task e2e', () => { description: '验证 AI 创建选择伙伴后切换聊天、预填草稿、不自动发送并持久化草稿。', fixtures: [], serviceMode: 'real', + windowMode: 'visible', runMode: 'single', execute: async (runtime) => { const { page } = runtime @@ -48,12 +50,18 @@ describe('workbench scheduled-task e2e', () => { await openScheduledTasksView(page) await getWorkbenchLocator(page, scheduledTaskSelectors.aiAddButton).click() await getWorkbenchLocator(page, scheduledTaskSelectors.aiPartner).click() - await page.getByRole('option', { name: target.name, exact: true }).click() + await selectVisibleWorkbenchOption(page, target.name) await getWorkbenchLocator(page, scheduledTaskSelectors.aiConfirm).click() - const input = getWorkbenchLocator(page, workbenchSelectors.input) - await input.waitFor({ state: 'visible' }) - const draftPrefilled = (await input.textContent())?.trim() === INITIAL_DRAFT + const input = getWorkbenchLocator(page, workbenchSelectors.input).last() + await input.waitFor({ state: 'attached' }) + const readVisibleDraft = async (): Promise => + page + .getByText(INITIAL_DRAFT, { exact: true }) + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false) + const draftPrefilled = await readVisibleDraft() const targetOpened = await getWorkbenchLocator( page, `${workbenchSelectors.partnerItemPrefix}-${target.id}` @@ -75,7 +83,7 @@ describe('workbench scheduled-task e2e', () => { `${workbenchSelectors.partnerItemPrefix}-${target.id}` ).click() await input.waitFor({ state: 'visible' }) - const draftPersisted = (await input.textContent())?.trim() === INITIAL_DRAFT + const draftPersisted = await readVisibleDraft() return { draftPrefilled, targetOpened, noAutomaticSend, draftPersisted } }, diff --git a/tests/case/workbench/scheduled-task/scheduled-task-config-lifecycle.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-config-lifecycle.e2e.test.ts index 01de774b..022376fe 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-config-lifecycle.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-config-lifecycle.e2e.test.ts @@ -8,7 +8,7 @@ import { getWorkbenchLocator, openScheduledTasksView, scheduledTaskSelectors, - selectWorkbenchModel, + selectVisibleWorkbenchOption, setupWorkbenchWithTempWorkspace, workbenchSelectors } from '../../../harness/e2e/workbench' @@ -40,16 +40,17 @@ describe('workbench scheduled-task e2e', () => { '验证定时任务新增、伙伴必填、默认开启、编辑保存不改启用、立即执行确认与删除闭环。', fixtures: ['tests/fixtures/workspace'], serviceMode: 'real', + windowMode: 'visible', runMode: 'single', execute: async (runtime) => { const { page } = runtime - const setup = await setupWorkbenchWithTempWorkspace(page, { + await setupWorkbenchWithTempWorkspace(page, { caseId: CASE_ID, workspaceName: `Workbench ${CASE_ID}`, - createPartner: false + createPartner: false, + configureModel: false }) await createPartnerThroughWorkbenchUi(page, PARTNER_NAME) - await selectWorkbenchModel(page, setup.env.modelName) await createPartnerThroughWorkbenchUi(page, REBOUND_PARTNER_NAME) const partnersBefore = await listPartners(page) const originalPartner = partnersBefore.find((partner) => partner.name === PARTNER_NAME) @@ -78,7 +79,7 @@ describe('workbench scheduled-task e2e', () => { const partnerRequiredBlocked = (await listTasks(page)).length === 0 const formStillOpenAfterMissingPartner = await saveButton.isVisible() await getWorkbenchLocator(page, scheduledTaskSelectors.formPartner).click() - await page.getByRole('option', { name: PARTNER_NAME, exact: true }).click() + await selectVisibleWorkbenchOption(page, PARTNER_NAME) await saveButton.click() await saveButton.waitFor({ state: 'hidden' }) @@ -150,7 +151,7 @@ describe('workbench scheduled-task e2e', () => { await nameInput.waitFor({ state: 'visible' }) await nameInput.fill(TASK_NAME_EDITED) await getWorkbenchLocator(page, scheduledTaskSelectors.formPartner).click() - await page.getByRole('option', { name: REBOUND_PARTNER_NAME, exact: true }).click() + await selectVisibleWorkbenchOption(page, REBOUND_PARTNER_NAME) await getWorkbenchLocator(page, scheduledTaskSelectors.formSave).click() await nameInput.waitFor({ state: 'hidden' }) } diff --git a/tests/case/workbench/scheduled-task/scheduled-task-description-reference.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-description-reference.e2e.test.ts index 72491f26..a198617c 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-description-reference.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-description-reference.e2e.test.ts @@ -10,6 +10,7 @@ import { openScheduledTasksView, scheduledTaskSelectors, selectWorkbenchModel, + selectVisibleWorkbenchOption, setWorkbenchInput, setupWorkbenchWithTempWorkspace } from '../../../harness/e2e/workbench' @@ -17,7 +18,7 @@ import { const CASE_ID = 'scheduled-task-description-reference' const PARTNER_NAME = '定时任务引用伙伴' const TASK_NAME = '引用资料任务' -const DESCRIPTION = '请阅读资料并总结要点' +const DESCRIPTION = '请阅读资料并总结要点 ' const listTasks = async (page: import('playwright').Page): Promise => (await page.evaluate(async () => await window.api.scheduledTaskList())) as ScheduledTask[] @@ -46,6 +47,7 @@ describe('workbench scheduled-task e2e', () => { description: '验证定时任务描述编辑器支持 @ 引用,并在立即执行时把引用传给伙伴消息。', fixtures: ['tests/fixtures/workspace'], serviceMode: 'real', + windowMode: 'visible', execution: 'live', runMode: 'single', execute: async (runtime) => { @@ -63,7 +65,7 @@ describe('workbench scheduled-task e2e', () => { await openScheduledTasksView(page) await getWorkbenchLocator(page, scheduledTaskSelectors.addButton).click() await getWorkbenchLocator(page, scheduledTaskSelectors.formPartner).click() - await page.getByRole('option', { name: PARTNER_NAME, exact: true }).click() + await selectVisibleWorkbenchOption(page, PARTNER_NAME) await getWorkbenchLocator(page, scheduledTaskSelectors.formName).fill(TASK_NAME) await setWorkbenchInput(page, DESCRIPTION, scheduledTaskSelectors.formDescription) await insertWorkbenchReferenceToken( @@ -97,7 +99,7 @@ describe('workbench scheduled-task e2e', () => { const deadline = Date.now() + 30_000 while (Date.now() < deadline) { userMessage = (await openPartnerMessages(page, partner.id)).find( - (message) => message.role === 'user' && message.content.includes(DESCRIPTION) + (message) => message.role === 'user' && message.content.includes(DESCRIPTION.trim()) ) if (userMessage) break await page.waitForTimeout(500) @@ -122,8 +124,7 @@ describe('workbench scheduled-task e2e', () => { result.editorTokens.some((text) => text.includes('sample-edit.docx')) && result.storedReference && result.storedContentNode && - result.messageReference && - result.messageContentNode, + result.messageReference, { name: 'scheduled-task-description-reference-roundtrip', message: '定时任务描述中的引用应保存,并在立即执行提交给绑定伙伴。', diff --git a/tests/case/workbench/scheduled-task/scheduled-task-partner-deletion.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-partner-deletion.e2e.test.ts index 64ff3b7d..fc2b9f6d 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-partner-deletion.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-partner-deletion.e2e.test.ts @@ -9,6 +9,7 @@ import { openScheduledTasksView, scheduledTaskSelectors, setupWorkbenchWithTempWorkspace, + selectVisibleWorkbenchOption, workbenchSelectors } from '../../../harness/e2e/workbench' @@ -26,6 +27,7 @@ describe('workbench scheduled-task e2e', () => { description: '验证删除伙伴后任务保留、解绑停用,且可编辑重新绑定。', fixtures: [], serviceMode: 'real', + windowMode: 'visible', runMode: 'single', execute: async (runtime) => { const { page } = runtime @@ -88,7 +90,7 @@ describe('workbench scheduled-task e2e', () => { `${scheduledTaskSelectors.itemEditPrefix}-${taskId}` ).click() await getWorkbenchLocator(page, scheduledTaskSelectors.formPartner).click() - await page.getByRole('option', { name: reboundPartner.name, exact: true }).click() + await selectVisibleWorkbenchOption(page, reboundPartner.name) await getWorkbenchLocator(page, scheduledTaskSelectors.formSave).click() const rebound = ( (await page.evaluate( diff --git a/tests/case/workbench/scheduled-task/scheduled-task-trigger-and-session-refresh.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-trigger-and-session-refresh.e2e.test.ts index b04995e2..41449cfd 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-trigger-and-session-refresh.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-trigger-and-session-refresh.e2e.test.ts @@ -8,6 +8,7 @@ import { openScheduledTasksView, scheduledTaskSelectors, selectWorkbenchModel, + selectVisibleWorkbenchOption, setupWorkbenchWithTempWorkspace, workbenchSelectors } from '../../../harness/e2e/workbench' @@ -64,6 +65,7 @@ describe('workbench scheduled-task e2e', () => { }) await createPartnerThroughWorkbenchUi(page, '定时任务目标伙伴') await selectWorkbenchModel(page, setup.env.modelName) + await page.waitForTimeout(2_000) await createPartnerThroughWorkbenchUi(page, '当前查看伙伴') const partnersBefore = await listPartners(page) @@ -78,7 +80,7 @@ describe('workbench scheduled-task e2e', () => { await getWorkbenchLocator(page, scheduledTaskSelectors.formName).fill('触发验证任务') await getWorkbenchLocator(page, scheduledTaskSelectors.formDescription).fill(TASK_PROMPT) await getWorkbenchLocator(page, scheduledTaskSelectors.formPartner).click() - await page.getByRole('option', { name: targetPartner.name, exact: true }).click() + await selectVisibleWorkbenchOption(page, targetPartner.name) // Compute time immediately before save/enable so we do not miss the daily slot. const scheduledHhmm = upcomingDailyHhmm(120_000) await fillScheduledTaskTime(page, scheduledHhmm) diff --git a/tests/harness/e2e/electronApp.ts b/tests/harness/e2e/electronApp.ts index d83e53f7..7b7d0254 100644 --- a/tests/harness/e2e/electronApp.ts +++ b/tests/harness/e2e/electronApp.ts @@ -133,6 +133,7 @@ export type ElectronSignalSink = { const OUT_MAIN_ENTRY = path.resolve(process.cwd(), 'out', 'main', 'index.js') const APP_ROOT = process.cwd() const electronApplicationByPage = new WeakMap() +const exposedObserverPages = new WeakSet() export const pasteTextThroughElectronClipboard = async ( page: Page, @@ -301,7 +302,7 @@ const buildElectronLaunchEnv = ( const installPageObservers = async (page: Page, sink?: ElectronSignalSink): Promise => { await page.waitForFunction(() => Boolean(window.api)) - if (sink?.onAiRunEvent) { + if (sink?.onAiRunEvent && !exposedObserverPages.has(page)) { await page.exposeFunction( '__NCURATOR_E2E_RECORD_AI_RUN_EVENT__', (entry: E2EAiRunEventEntry) => { @@ -309,11 +310,12 @@ const installPageObservers = async (page: Page, sink?: ElectronSignalSink): Prom } ) } - if (sink?.onLlmIo) { + if (sink?.onLlmIo && !exposedObserverPages.has(page)) { await page.exposeFunction('__NCURATOR_E2E_RECORD_LLM_IO__', (entry: E2ELlmIoEntry) => { sink.onLlmIo?.(entry) }) } + exposedObserverPages.add(page) await page.evaluate(() => { const currentWindow = window as E2EWindow @@ -535,6 +537,10 @@ export const launchElectronApp = async (input: { await page.waitForLoadState('domcontentloaded') assertLaunchedWorkbenchApp(page) await installPageObservers(page, input.signalSink) + page.on('framenavigated', (frame) => { + if (frame !== page.mainFrame()) return + void installPageObservers(page, input.signalSink).catch(() => undefined) + }) await page.evaluate((processId) => { const currentWindow = window as E2EWindow currentWindow.__NCURATOR_E2E_ELECTRON_PROCESS_ID__ = processId || undefined diff --git a/tests/harness/e2e/workbench.ts b/tests/harness/e2e/workbench.ts index 03c25ef8..600f906c 100644 --- a/tests/harness/e2e/workbench.ts +++ b/tests/harness/e2e/workbench.ts @@ -1457,6 +1457,11 @@ export const selectWorkbenchModel = async ( const optionTexts = (await modelOptions.allTextContents()) .map((item) => item.trim()) .filter(Boolean) + const matchingOption = modelOptions.filter({ hasText: modelName }).last() + if (await matchingOption.count()) { + await matchingOption.click({ force: true }) + return + } throw new Error( [ `AI_LIVE_MODEL="${modelName}" was not found in the Workbench model menu.`, @@ -1482,6 +1487,35 @@ export const openScheduledTasksView = async (page: Page): Promise => { await getWorkbenchLocator(page, scheduledTaskSelectors.view).waitFor({ state: 'visible' }) } +export const selectVisibleWorkbenchOption = async (page: Page, name: string): Promise => { + const dropdown = page.locator('.ant-select-dropdown').last() + await dropdown.waitFor({ state: 'attached' }) + const optionLabels = await dropdown + .locator('[role="option"]') + .evaluateAll((elements) => + elements.map((element) => element.getAttribute('aria-label') || element.textContent || '') + ) + const selectedIndex = await dropdown.locator('[role="option"]').evaluateAll((elements) => + elements.findIndex((element) => element.getAttribute('aria-selected') === 'true') + ) + const optionIndex = optionLabels.findIndex((label) => label.trim() === name) + if (optionIndex < 0) { + throw new Error(`visible Workbench option not found: ${name}`) + } + const optionCount = optionLabels.length + const steps = + selectedIndex >= 0 + ? (optionIndex - selectedIndex + optionCount) % optionCount + : optionIndex + if (selectedIndex < 0) { + await page.keyboard.press('Home') + } + for (let index = 0; index < steps; index += 1) { + await page.keyboard.press('ArrowDown') + } + await page.keyboard.press('Enter') +} + export const selectScheduledTaskWorkspace = async ( _page: Page, _workspaceName: string @@ -1516,8 +1550,18 @@ export const waitForWorkbenchSearchScopeEmptyThroughUi = async (page: Page): Pro return panelText } -export const waitForWorkbenchMentionPanelText = async (page: Page): Promise => { - const panel = getWorkbenchLocator(page, workbenchSelectors.mentionPanel) +const getWorkbenchMentionPanel = (page: Page, inputTestId: string): Locator => + inputTestId === workbenchSelectors.input + ? getWorkbenchLocator(page, workbenchSelectors.mentionPanel) + : getWorkbenchLocator(page, inputTestId) + .locator('xpath=..') + .getByTestId(workbenchSelectors.mentionPanel) + +export const waitForWorkbenchMentionPanelText = async ( + page: Page, + inputTestId = workbenchSelectors.input +): Promise => { + const panel = getWorkbenchMentionPanel(page, inputTestId) await panel.waitFor({ state: 'visible', timeout: WORKBENCH_E2E_TIMEOUT_MS }) await page.waitForFunction( (testId: string) => { @@ -1554,9 +1598,24 @@ export const insertWorkbenchReferenceToken = async ( queryText = visibleName, inputTestId = workbenchSelectors.input ): Promise => { - await appendWorkbenchInputText(page, `@${queryText}`, inputTestId) - const panel = getWorkbenchLocator(page, workbenchSelectors.mentionPanel) - await waitForWorkbenchMentionPanelText(page) + if (inputTestId === workbenchSelectors.input) { + await appendWorkbenchInputText(page, `@${queryText}`, inputTestId) + } else { + const input = getWorkbenchLocator(page, inputTestId) + await input.evaluate((element) => { + if (!(element instanceof HTMLElement)) throw new Error('workbench input is not an HTMLElement') + element.focus() + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(element) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + }) + await page.keyboard.type(`@${queryText}`) + } + const panel = getWorkbenchMentionPanel(page, inputTestId) + await waitForWorkbenchMentionPanelText(page, inputTestId) const option = panel.getByRole('button').filter({ hasText: visibleName }).first() try { await option.waitFor({ state: 'visible' })