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:
1 parent
9b6fc1fce4
commit
4a9dfd46d5
26 files changed
+631
-117
No files matched your search
@@ -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`
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
+6
-5
@@ -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(
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in new issue
Block a user