feat: enhance question interaction handling in assistant workflow

- Implemented resolveQuestionInteraction utility to extract interaction details from assistant process items.
- Updated AssistantActivityItemView to utilize the new interaction resolution logic.
- Refactored messageLogProjection to ensure thoughts are only created when thinking is open.
- Enhanced reduceAssistantProcessGroups to handle human interaction events and apply question interaction results.
- Added applyQuestionInteractionResult to manage answers from resolved interactions.
- Updated tests to cover new interaction handling and ensure correct behavior across various scenarios.
- Improved e2e tests for scheduled tasks to validate interaction flows and partner management.
This commit is contained in:
hy committed 2026-09-22 13:36:55 +08:00
1 parent 9b6fc1fce4
commit 4a9dfd46d5
26 files changed
+631 -117

No files matched your search

+4 -4
View File
@@ -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`
+16
View File
@@ -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`
+6 -3
View File
@@ -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)
+10 -3
View File
@@ -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)
}
)
@@ -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,
@@ -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')
}
@@ -26,7 +26,7 @@ export const ASK_USER_QUESTION_TOOL_SPEC: HumanToolSpec<
promptSpec: {
name: 'ask_user_question',
description:
'在推进任务时,若遇到无法从已有上下文、资料或工具结果可靠判断,且不同理解可能改变用户意图、执行方案或最终结果的关键歧义,请向用户确认。对于可以自行判断或通过读取资料确定的信息不要询问。'
'在推进任务时,如果碰到无法从已有上下文、资料或工具结果可靠判断的疑惑、歧义或可能违背用户意图的地方,向用户询问并沟通确认。对于可以自行判断或通过读取资料确定的信息不要询问;只有不同理解可能改变用户意图、执行方案或最终结果时才提问。'
},
resolveExecutionMeta: createStaticExecutionMetaResolver({
mutability: 'read_only',
@@ -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<typeof SCHEDULED_TASK_LIST_SCHEMA>
export type ScheduledTaskActionInput = z.infer<typeof SCHEDULED_TASK_ACTION_SCHEMA>
@@ -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<string, never>,
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<typeof scheduledTaskActionSchema>,
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<typeof scheduledTaskActionSchema>
export type { ScheduledTaskActionInput } from './toolSchemas'
@@ -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 (
<div className={failed ? 'text-[#B3261E]' : undefined}>
{renderSegments(toolText)}
{item.interaction ? <InteractionResultView interaction={item.interaction} /> : null}
{interaction ? <InteractionResultView interaction={interaction} /> : null}
{thumbs}
</div>
)
@@ -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)
+81 -1
View File
@@ -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<AssistantProcessGroupItem['interaction']>['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
+18 -2
View File
@@ -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 || [],
@@ -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
]
}
}
)
@@ -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: '工具应只返回当前伙伴的最小任务字段,并原子更新配置和启用状态。',
@@ -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)
@@ -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',
@@ -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: '深绿色和白色' }
])
})
})
@@ -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
)
@@ -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<boolean> =>
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 }
},
@@ -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' })
}
@@ -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<ScheduledTask[]> =>
(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: '定时任务描述中的引用应保存,并在立即执行提交给绑定伙伴。',
@@ -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(
@@ -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)
+8 -2
View File
@@ -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<Page, ElectronApplication>()
const exposedObserverPages = new WeakSet<Page>()
export const pasteTextThroughElectronClipboard = async (
page: Page,
@@ -301,7 +302,7 @@ const buildElectronLaunchEnv = (
const installPageObservers = async (page: Page, sink?: ElectronSignalSink): Promise<void> => {
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
+64 -5
View File
@@ -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<void> => {
await getWorkbenchLocator(page, scheduledTaskSelectors.view).waitFor({ state: 'visible' })
}
export const selectVisibleWorkbenchOption = async (page: Page, name: string): Promise<void> => {
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<string> => {
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<string> => {
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<void> => {
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' })