diff --git a/docs/module-design.md b/docs/module-design.md index 02f7ba312..646f514d0 100644 --- a/docs/module-design.md +++ b/docs/module-design.md @@ -1323,7 +1323,7 @@ flowchart TB #### 上下文范围 - 请求入口上下文:`source / trigger / input_type / input_mode / text` -- 近场对象上下文:`selection_text / error_text / files` +- 近场对象上下文:`selection_text / error.message / files` - 页面与窗口上下文:`page_title / page_url / app_name / window_title / visible_text` - 屏幕与行为上下文:`screen_summary / hover_target / last_action / dwell_millis / copy_count / switch_count` - 系统补充上下文:`clipboard_text` diff --git a/docs/protocol-design.md b/docs/protocol-design.md index 602e2f809..1dbfea36d 100644 --- a/docs/protocol-design.md +++ b/docs/protocol-design.md @@ -590,6 +590,49 @@ Notification 只负责“状态变化推送”,不承载复杂业务命令。 ## 8.1 入口承接 / 语音 / 场景助手 +### 8.1.0 共享 `InputContext` 上下文字段 + +`agent.input.submit` 与 `agent.task.start` 共享同一个稳定 `InputContext` 包。 +两条入口若传入 `context`,其字段集合与语义必须保持一致,不允许只在其中一条入口单独扩字段。 + +| 字段 | 中文说明 | +| ---- | -------- | +| `context.page` | 当前页面上下文 | +| `context.page.title` | 当前页面标题 | +| `context.page.url` | 当前页面 URL | +| `context.page.browser_kind` | 当前浏览器分类,取值为 `chrome / edge / other_browser / non_browser` | +| `context.page.process_path` | 当前宿主进程路径 | +| `context.page.process_id` | 当前宿主进程 ID | +| `context.page.app_name` | 当前宿主应用名 | +| `context.page.window_title` | 当前窗口标题 | +| `context.page.visible_text` | 当前页面可见文本摘录 | +| `context.page.hover_target` | 当前悬停目标摘要,按需传入 | +| `context.screen.summary` | 当前屏幕摘要,可用于视觉型任务上下文 | +| `context.screen.screen_summary` | 当前屏幕摘要的结构化别名,按需传入 | +| `context.screen.visible_text` | 当前屏幕可见文本摘录 | +| `context.screen.window_title` | 当前屏幕对应窗口标题,按需传入 | +| `context.screen.hover_target` | 当前屏幕上的悬停目标摘要,按需传入 | +| `context.behavior.last_action` | 最近行为信号,例如 `copy` | +| `context.behavior.dwell_millis` | 当前场景停留时长 | +| `context.behavior.copy_count` | 最近复制次数,按需传入 | +| `context.behavior.window_switch_count` | 最近窗口切换次数,按需传入 | +| `context.behavior.page_switch_count` | 最近页面切换次数,按需传入 | +| `context.selection.text` | 当前选区补充文本,按需传入 | +| `context.error.message` | 当前错误文本,按需传入 | +| `context.clipboard.text` | 当前剪贴板文本,按需传入 | +| `context.text` | 当前自由文本补充,按需传入 | +| `context.selection_text` | 当前选区的平铺补充文本,按需传入 | +| `context.files` | 当前附带文件列表,按需传入 | +| `context.file_paths` | 兼容文件路径列表,按需传入 | +| `context.screen_summary` | 当前屏幕摘要的平铺别名,按需传入 | +| `context.clipboard_text` | 当前剪贴板文本的平铺别名,按需传入 | +| `context.hover_target` | 当前悬停目标摘要的平铺别名,按需传入 | +| `context.last_action` | 最近行为信号的平铺别名,按需传入 | +| `context.dwell_millis` | 当前场景停留时长的平铺别名,按需传入 | +| `context.copy_count` | 最近复制次数的平铺别名,按需传入 | +| `context.window_switch_count` | 最近窗口切换次数的平铺别名,按需传入 | +| `context.page_switch_count` | 最近页面切换次数的平铺别名,按需传入 | + ### 8.1.1 `agent.input.submit` - **请求方式**:JSON-RPC 2.0 @@ -617,21 +660,7 @@ Notification 只负责“状态变化推送”,不承载复杂业务命令。 | `input.type` | 输入对象类型,固定为 `text` | | `input.text` | 用户输入文本 | | `input.input_mode` | 输入模式,语音或文字 | -| `context.page` | 当前页面上下文 | -| `context.page.title` | 当前页面标题 | -| `context.page.url` | 当前页面 URL | -| `context.page.browser_kind` | 当前浏览器分类,取值为 `chrome / edge / other_browser / non_browser` | -| `context.page.process_path` | 当前宿主进程路径 | -| `context.page.process_id` | 当前宿主进程 ID | -| `context.page.app_name` | 当前宿主应用名 | -| `context.page.window_title` | 当前窗口标题 | -| `context.page.visible_text` | 当前页面可见文本摘录 | -| `context.selection.text` | 当前选中文本 | -| `context.files` | 当前附带文件列表 | -| `context.screen.summary` | 当前屏幕摘要,可用于视觉型任务上下文 | -| `context.screen.visible_text` | 当前屏幕可见文本摘录 | -| `context.behavior.last_action` | 最近行为信号,例如 `copy` | -| `context.behavior.dwell_millis` | 当前场景停留时长 | +| `context` | 共享上下文包,字段定义见上方共享 `InputContext` 表 | | `voice_meta` | 语音会话元信息 | | `options.confirm_required` | 是否强制先进入意图确认 | | `options.preferred_delivery` | 偏好的结果交付方式 | @@ -808,12 +837,7 @@ Notification 只负责“状态变化推送”,不承载复杂业务命令。 | `input.page_context.app_name` | 当前宿主应用名 | | `input.page_context.window_title` | 当前窗口标题 | | `input.page_context.visible_text` | 当前页面可见文本摘录 | -| `context.selection.text` | 当前选区补充文本,按需传入 | -| `context.files` | 补充文件上下文,按需传入 | -| `context.screen.summary` | 当前屏幕摘要,可用于视觉型任务上下文 | -| `context.screen.visible_text` | 当前屏幕可见文本摘录 | -| `context.behavior.last_action` | 最近行为信号,例如 `copy` | -| `context.behavior.dwell_millis` | 当前场景停留时长 | +| `context` | 共享上下文包,字段定义见上方共享 `InputContext` 表 | | `delivery.preferred` | 优先交付方式 | | `delivery.fallback` | 兜底交付方式 | | `options.confirm_required` | 是否强制先进入意图确认;不用于绕过风险授权 | @@ -855,14 +879,16 @@ Notification 只负责“状态变化推送”,不承载复杂业务命令。 "text": "这里放用户选中的文本内容", "page_context": { "app_name": "Chrome", - "url": "https://example.com/release", - "image_url": "xxx.png" + "url": "https://example.com/release" } }, "context": { "selection": { "text": "这里是补充上下文" }, + "error": { + "message": "页面上显示了一段失败日志" + }, "files": [] }, "delivery": { diff --git a/packages/protocol/rpc/methods.ts b/packages/protocol/rpc/methods.ts index 7036ceabd..257307d50 100644 --- a/packages/protocol/rpc/methods.ts +++ b/packages/protocol/rpc/methods.ts @@ -865,7 +865,7 @@ export interface AgentSettingsModelValidateResult { // AgentPluginRuntimeListParams defines the stable plugin runtime query params. export interface AgentPluginRuntimeListParams { - request_meta?: RequestMeta; + request_meta: RequestMeta; } // AgentPluginRuntimeListResult defines the plugin runtime query result. @@ -876,7 +876,7 @@ export interface AgentPluginRuntimeListResult { } export interface AgentPluginListParams { - request_meta?: RequestMeta; + request_meta: RequestMeta; page?: { limit: number; offset: number; @@ -892,7 +892,7 @@ export interface AgentPluginListResult { } export interface AgentPluginDetailGetParams { - request_meta?: RequestMeta; + request_meta: RequestMeta; plugin_id: string; include_runtime?: boolean; include_metrics?: boolean; diff --git a/services/local-service/internal/orchestrator/approval_state.go b/services/local-service/internal/orchestrator/approval_state.go index fed000bee..f54ea8b23 100644 --- a/services/local-service/internal/orchestrator/approval_state.go +++ b/services/local-service/internal/orchestrator/approval_state.go @@ -16,16 +16,19 @@ func (s *Service) resumeQueuedControlledTask(task runengine.TaskRecord) (runengi if stringValue(task.Intent, "name", "") != "screen_analyze" { return task, false, nil } - approvalRequest, pendingExecution, bubble, err := s.buildScreenAnalysisApprovalState(task) + approvalState, err := s.buildScreenAnalysisApprovalState(task) if err != nil { - failedTask, _ := s.failExecutionTask(task, map[string]any{"name": "screen_analyze"}, execution.Result{}, err) + failedTask, _ := s.failExecutionTask(task, protocolIntentMap("screen_analyze", nil), execution.Result{}, err) return failedTask, true, nil } + approvalRequest := approvalState.approvalRequestMap() + pendingExecution := approvalState.pendingExecutionMap() + bubble := approvalState.bubbleMessageMap() updatedTask, ok := s.runEngine.MarkWaitingApprovalWithPlan(task.TaskID, approvalRequest, pendingExecution, bubble) if !ok { return runengine.TaskRecord{}, true, ErrTaskNotFound } - if err := s.persistApprovalRequestState(updatedTask.TaskID, approvalRequest, mapValue(pendingExecution, "impact_scope")); err != nil { + if err := s.persistApprovalRequestState(updatedTask.TaskID, approvalRequest, approvalState.PendingExecution.ImpactScope.mapValue()); err != nil { return runengine.TaskRecord{}, true, err } return updatedTask, true, nil diff --git a/services/local-service/internal/orchestrator/governance.go b/services/local-service/internal/orchestrator/governance.go index d03b1454d..193033b78 100644 --- a/services/local-service/internal/orchestrator/governance.go +++ b/services/local-service/internal/orchestrator/governance.go @@ -81,15 +81,15 @@ func (s *Service) assessTaskGovernance(task runengine.TaskRecord, taskIntent map }) } -func (s *Service) handleTaskGovernanceDecision(task runengine.TaskRecord, taskIntent map[string]any) (runengine.TaskRecord, map[string]any, bool, error) { +func (s *Service) handleTaskGovernanceDecision(task runengine.TaskRecord, taskIntent map[string]any) (runengine.TaskRecord, TaskEntryResponse, bool, error) { assessment, ok, err := s.assessTaskGovernance(task, taskIntent) if err != nil { - return task, nil, false, err + return task, TaskEntryResponse{}, false, err } if !ok { assessment, ok = s.fallbackGovernanceAssessment(task, taskIntent) if !ok { - return task, nil, false, nil + return task, TaskEntryResponse{}, false, nil } } if assessment.Deny { @@ -97,7 +97,7 @@ func (s *Service) handleTaskGovernanceDecision(task runengine.TaskRecord, taskIn return blockedTask, response, true, blockErr } if !assessment.ApprovalRequired { - return task, nil, false, nil + return task, TaskEntryResponse{}, false, nil } pendingExecution := s.applyGovernanceAssessment(s.buildPendingExecution(task, taskIntent), assessment) approvalRequest := buildApprovalRequest(task.TaskID, taskIntent, assessment) @@ -110,16 +110,16 @@ func (s *Service) handleTaskGovernanceDecision(task runengine.TaskRecord, taskIn updatedTask, changed = s.runEngine.MarkWaitingApprovalWithPlan(task.TaskID, approvalRequest, pendingExecution, bubble) } if !changed { - return task, nil, false, ErrTaskNotFound + return task, TaskEntryResponse{}, false, ErrTaskNotFound } if err := s.persistApprovalRequestState(updatedTask.TaskID, approvalRequest, assessment.ImpactScope); err != nil { - return task, nil, false, err + return task, TaskEntryResponse{}, false, err } - return updatedTask, map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": bubble, - "delivery_result": nil, - }, true, nil + response, err := buildTaskEntryResponse(&updatedTask, bubble, nil) + if err != nil { + return task, TaskEntryResponse{}, false, err + } + return updatedTask, response, true, nil } func (s *Service) maybePauseForRuntimeApproval(task runengine.TaskRecord, taskIntent map[string]any, result execution.Result) (runengine.TaskRecord, map[string]any, bool, error) { @@ -151,11 +151,7 @@ func (s *Service) maybePauseForRuntimeApproval(task runengine.TaskRecord, taskIn if err := s.persistApprovalRequestState(updatedTask.TaskID, approvalRequest, assessment.ImpactScope); err != nil { return task, nil, false, err } - return updatedTask, map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": bubble, - "delivery_result": nil, - }, true, nil + return updatedTask, bubble, true, nil } // runtimeApprovalAssessment reconstructs the formal approval_request boundary @@ -280,7 +276,7 @@ func (s *Service) fallbackGovernanceAssessment(task runengine.TaskRecord, taskIn }, true } -func (s *Service) blockTaskByAssessment(task runengine.TaskRecord, assessment execution.GovernanceAssessment) (map[string]any, runengine.TaskRecord, error) { +func (s *Service) blockTaskByAssessment(task runengine.TaskRecord, assessment execution.GovernanceAssessment) (TaskEntryResponse, runengine.TaskRecord, error) { bubbleText := governanceInterceptionBubble(assessment) bubble := s.delivery.BuildBubbleMessage(task.TaskID, "status", bubbleText, task.UpdatedAt.Format(dateTimeLayout)) updatedTask := runengine.TaskRecord{} @@ -291,16 +287,15 @@ func (s *Service) blockTaskByAssessment(task runengine.TaskRecord, assessment ex updatedTask, ok = s.runEngine.BlockTaskByPolicy(task.TaskID, assessment.RiskLevel, bubbleText, assessment.ImpactScope, bubble) } if !ok { - return nil, task, ErrTaskNotFound + return TaskEntryResponse{}, task, ErrTaskNotFound } auditRecord := s.writeGovernanceAuditRecord(updatedTask.TaskID, updatedTask.RunID, "risk", "intercept_operation", bubbleText, impactScopeTarget(assessment.ImpactScope, assessment.TargetObject), "denied") updatedTask = s.appendAuditData(updatedTask, compactAuditRecords(auditRecord), nil) - return map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": bubble, - "delivery_result": nil, - "impact_scope": cloneMap(assessment.ImpactScope), - }, updatedTask, nil + response, err := buildTaskEntryResponse(&updatedTask, bubble, nil) + if err != nil { + return TaskEntryResponse{}, task, err + } + return response, updatedTask, nil } func (s *Service) writeGovernanceAuditRecord(taskID, runID, auditType, action, summary, target, result string) map[string]any { diff --git a/services/local-service/internal/orchestrator/input_submit.go b/services/local-service/internal/orchestrator/input_submit.go index ec53131ed..6dfa96459 100644 --- a/services/local-service/internal/orchestrator/input_submit.go +++ b/services/local-service/internal/orchestrator/input_submit.go @@ -11,7 +11,17 @@ import ( // SubmitInput adapts free-form user input into the task-centric execution path. // It captures context, derives an intent suggestion, then either waits for more // input, asks for confirmation, or creates the task/run pair for execution. -func (s *Service) SubmitInput(params map[string]any) (map[string]any, error) { +func (s *Service) SubmitInput(request SubmitInputRequest) (TaskEntryResponse, error) { + return s.SubmitInputFromParams(request.ProtocolParamsMap()) +} + +// SubmitInputFromParams lets the RPC layer reuse the normalized protocol map it +// already validated so submit-input requests avoid an extra DTO-to-map bounce. +func (s *Service) SubmitInputFromParams(params map[string]any) (TaskEntryResponse, error) { + return s.submitInput(params) +} + +func (s *Service) submitInput(params map[string]any) (TaskEntryResponse, error) { flow := s.prepareInputSubmitFlow(params) if response, handled, err := s.maybeContinueInputSubmit(&flow); err != nil || handled { return response, err @@ -19,8 +29,8 @@ func (s *Service) SubmitInput(params map[string]any) (map[string]any, error) { if response, handled, err := s.maybeHandleSuggestedInputScreen(flow); err != nil || handled { return response, err } - if response, handled := s.maybeRouteUnanchoredInput(&flow); handled { - return response, nil + if response, handled, err := s.maybeRouteUnanchoredInput(&flow); err != nil || handled { + return response, err } flow.PreferredDelivery, flow.FallbackDelivery = inputSubmitDeliveryPreference(flow) if response, handled, err := s.maybeCreateWaitingInputTask(flow); err != nil || handled { @@ -46,7 +56,7 @@ func (s *Service) prepareInputSubmitFlow(params map[string]any) taskEntryFlow { } } -func (s *Service) maybeContinueInputSubmit(flow *taskEntryFlow) (map[string]any, bool, error) { +func (s *Service) maybeContinueInputSubmit(flow *taskEntryFlow) (TaskEntryResponse, bool, error) { response, handled, resolvedSessionID, err := s.maybeContinueExistingTask(flow.Params, flow.Snapshot, nil, taskContinuationOptions{ ConfirmRequired: flow.ConfirmRequired, ForceConfirmRequired: flow.ForceConfirmRequired, @@ -57,23 +67,27 @@ func (s *Service) maybeContinueInputSubmit(flow *taskEntryFlow) (map[string]any, if strings.TrimSpace(resolvedSessionID) != "" { flow.Params = withResolvedSessionID(flow.Params, resolvedSessionID) } - return nil, false, nil + return TaskEntryResponse{}, false, nil } -func (s *Service) maybeHandleSuggestedInputScreen(flow taskEntryFlow) (map[string]any, bool, error) { +func (s *Service) maybeHandleSuggestedInputScreen(flow taskEntryFlow) (TaskEntryResponse, bool, error) { return s.handleScreenAnalyzeSuggestion(flow.Params, flow.Snapshot, flow.Suggestion) } -func (s *Service) maybeRouteUnanchoredInput(flow *taskEntryFlow) (map[string]any, bool) { +func (s *Service) maybeRouteUnanchoredInput(flow *taskEntryFlow) (TaskEntryResponse, bool, error) { decision, ok := s.routeUnanchoredSubmitInput(context.Background(), flow.Snapshot, flow.Suggestion, flow.ConfirmRequired) if !ok { - return nil, false + return TaskEntryResponse{}, false, nil } if decision.Route == inputRouteSocialChat { - return s.socialChatInputResponse(decision), true + response, err := s.socialChatInputResponse(decision) + if err != nil { + return TaskEntryResponse{}, false, err + } + return response, true, nil } flow.Suggestion = applyInputRouteDecision(flow.Suggestion, decision) - return nil, false + return TaskEntryResponse{}, false, nil } func inputSubmitDeliveryPreference(flow taskEntryFlow) (string, string) { @@ -84,9 +98,9 @@ func inputSubmitDeliveryPreference(flow taskEntryFlow) (string, string) { return preferredDelivery, fallbackDelivery } -func (s *Service) maybeCreateWaitingInputTask(flow taskEntryFlow) (map[string]any, bool, error) { +func (s *Service) maybeCreateWaitingInputTask(flow taskEntryFlow) (TaskEntryResponse, bool, error) { if s.intent.AnalyzeSnapshot(flow.Snapshot) != "waiting_input" { - return nil, false, nil + return TaskEntryResponse{}, false, nil } task := s.runEngine.CreateTask(runengine.CreateTaskInput{ SessionID: stringValue(flow.Params, "session_id", ""), @@ -106,24 +120,25 @@ func (s *Service) maybeCreateWaitingInputTask(flow taskEntryFlow) (map[string]an bubble := s.delivery.BuildBubbleMessage(task.TaskID, "status", presentation.Text(presentation.MessageBubbleInputNeedGoal, nil), task.StartedAt.Format(dateTimeLayout)) task = s.persistTaskPresentation(task, bubble) - return buildTaskEntryResponse(task, bubble, nil), true, nil + response, err := buildTaskEntryResponse(&task, bubble, nil) + return response, true, err } -func (s *Service) finishInputSubmit(flow taskEntryFlow, task runengine.TaskRecord) (map[string]any, error) { +func (s *Service) finishInputSubmit(flow taskEntryFlow, task runengine.TaskRecord) (TaskEntryResponse, error) { bubble := s.delivery.BuildBubbleMessage(task.TaskID, bubbleTypeForSuggestion(flow.Suggestion.RequiresConfirm), bubbleTextForInput(flow.Suggestion), task.StartedAt.Format(dateTimeLayout)) if flow.Suggestion.RequiresConfirm { task = s.persistTaskPresentation(task, bubble) - return buildTaskEntryResponse(task, bubble, nil), nil + return buildTaskEntryResponse(&task, bubble, nil) } if queuedTask, queueBubble, queued, queueErr := s.queueTaskIfSessionBusy(task); queueErr != nil { - return nil, queueErr + return TaskEntryResponse{}, queueErr } else if queued { - return buildTaskEntryResponse(queuedTask, queueBubble, nil), nil + return buildTaskEntryResponse(&queuedTask, queueBubble, nil) } governedTask, governedResponse, handled, governanceErr := s.handleTaskGovernanceDecision(task, flow.Suggestion.Intent) if governanceErr != nil { - return nil, governanceErr + return TaskEntryResponse{}, governanceErr } if handled { return governedResponse, nil @@ -134,9 +149,9 @@ func (s *Service) finishInputSubmit(flow taskEntryFlow, task runengine.TaskRecor var execErr error task, bubble, deliveryResult, _, execErr = s.executeTask(task, flow.Snapshot, flow.Suggestion.Intent) if execErr != nil { - return nil, execErr + return TaskEntryResponse{}, execErr } - return buildTaskEntryResponse(task, bubble, deliveryResult), nil + return buildTaskEntryResponse(&task, bubble, deliveryResult) } // deliveryPreferenceFromSubmit reads delivery preferences from diff --git a/services/local-service/internal/orchestrator/protocol_dto_test.go b/services/local-service/internal/orchestrator/protocol_dto_test.go new file mode 100644 index 000000000..218b83cb9 --- /dev/null +++ b/services/local-service/internal/orchestrator/protocol_dto_test.go @@ -0,0 +1,205 @@ +package orchestrator + +import "testing" + +func TestStartTaskRequestFromParamsNormalizesUnknownFields(t *testing.T) { + request := StartTaskRequestFromParams(map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_normalize", + "client_time": "2026-05-10T00:00:00Z", + }, + "session_id": "sess_task_start_normalize", + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "selected content", + "unknown_field": "drop-me", + "page_context": map[string]any{ + "title": "Editor", + "unknown_field": "drop-me", + }, + }, + "context": map[string]any{ + "selection": map[string]any{ + "text": "selected content", + "unknown_field": "drop-me", + }, + }, + "unknown_field": "drop-me", + }) + + params := request.ProtocolParamsMap() + if _, ok := params["unknown_field"]; ok { + t.Fatalf("expected typed request normalization to drop unknown top-level fields, got %+v", params) + } + input := mapValue(params, "input") + if _, ok := input["unknown_field"]; ok { + t.Fatalf("expected typed request normalization to drop unknown input fields, got %+v", input) + } + pageContext := mapValue(input, "page_context") + if _, ok := pageContext["unknown_field"]; ok { + t.Fatalf("expected typed request normalization to drop unknown page_context fields, got %+v", pageContext) + } + selection := mapValue(mapValue(params, "context"), "selection") + if _, ok := selection["unknown_field"]; ok { + t.Fatalf("expected typed request normalization to drop unknown selection fields, got %+v", selection) + } + if stringValue(input, "text", "") != "selected content" { + t.Fatalf("expected typed request normalization to preserve declared fields, got %+v", input) + } +} + +func TestTaskEntryResponseMapNormalizesUnknownFields(t *testing.T) { + response, err := newTaskEntryResponse(map[string]any{ + "task": map[string]any{ + "task_id": "task_123", + "session_id": "sess_123", + "title": "Summarize selection", + "source_type": "selection", + "status": "processing", + "intent": nil, + "current_step": "generate_output", + "risk_level": "green", + "started_at": "2026-05-10T00:00:00Z", + "updated_at": "2026-05-10T00:00:00Z", + "finished_at": nil, + "unknown": "drop-me", + }, + "bubble_message": map[string]any{ + "bubble_id": "bubble_123", + "task_id": "task_123", + "type": "result", + "text": "Done", + "pinned": false, + "hidden": false, + "created_at": "2026-05-10T00:00:00Z", + "unknown": "drop-me", + }, + "delivery_result": nil, + "unknown": "drop-me", + }) + if err != nil { + t.Fatalf("build task entry response failed: %v", err) + } + + mapped := response.Map() + if _, ok := mapped["unknown"]; ok { + t.Fatalf("expected typed response normalization to drop unknown top-level fields, got %+v", mapped) + } + task := mapValue(mapped, "task") + if _, ok := task["unknown"]; ok { + t.Fatalf("expected typed response normalization to drop unknown task fields, got %+v", task) + } + bubble := mapValue(mapped, "bubble_message") + if _, ok := bubble["unknown"]; ok { + t.Fatalf("expected typed response normalization to drop unknown bubble fields, got %+v", bubble) + } + if stringValue(task, "task_id", "") != "task_123" { + t.Fatalf("expected typed response normalization to preserve declared task fields, got %+v", task) + } +} + +func TestTaskEntryResponseIgnoresUnknownNonJSONFields(t *testing.T) { + response, err := newTaskEntryResponse(map[string]any{ + "task": map[string]any{ + "task_id": "task_non_json_unknown", + "title": "Ignore unknown function field", + "source_type": "floating_ball", + "status": "completed", + "current_step": "deliver_result", + "risk_level": "green", + "started_at": "2026-05-10T00:00:00Z", + "updated_at": "2026-05-10T00:00:01Z", + }, + "unknown": func() {}, + }) + if err != nil { + t.Fatalf("expected unknown non-json field to stay outside response dto, got %v", err) + } + + task := mapValue(response.Map(), "task") + if stringValue(task, "task_id", "") != "task_non_json_unknown" { + t.Fatalf("expected direct response mapping to preserve declared fields, got %+v", task) + } +} + +func TestTaskEntryResponseRejectsMissingRequiredDeclaredFields(t *testing.T) { + testCases := []struct { + name string + payload map[string]any + }{ + { + name: "task.status", + payload: map[string]any{ + "task": map[string]any{ + "task_id": "task_missing_status", + "title": "Missing status", + "source_type": "floating_ball", + "current_step": "deliver_result", + "risk_level": "green", + "updated_at": "2026-05-10T00:00:01Z", + }, + }, + }, + { + name: "bubble_message.pinned", + payload: map[string]any{ + "task": map[string]any{ + "task_id": "task_with_bubble_missing_bool", + "title": "Missing bubble bool", + "source_type": "floating_ball", + "status": "completed", + "current_step": "deliver_result", + "risk_level": "green", + "updated_at": "2026-05-10T00:00:01Z", + }, + "bubble_message": map[string]any{ + "bubble_id": "bubble_missing_pinned", + "task_id": "task_with_bubble_missing_bool", + "type": "result", + "text": "Done", + "hidden": false, + "created_at": "2026-05-10T00:00:01Z", + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + if _, err := newTaskEntryResponse(testCase.payload); err == nil { + t.Fatalf("expected missing required declared field to fail for %s", testCase.name) + } + }) + } +} + +func TestTaskEntryResponseNormalizesIntentArgumentsToStableObject(t *testing.T) { + response, err := newTaskEntryResponse(map[string]any{ + "task": map[string]any{ + "task_id": "task_intent_arguments", + "session_id": "sess_intent_arguments", + "title": "Preserve empty intent arguments", + "source_type": "floating_ball", + "status": "processing", + "intent": map[string]any{"name": "agent_loop"}, + "current_step": "awaiting_execution", + "risk_level": "green", + "started_at": "2026-05-10T00:00:00Z", + "updated_at": "2026-05-10T00:00:01Z", + }, + }) + if err != nil { + t.Fatalf("build task entry response failed: %v", err) + } + + intent := mapValue(mapValue(response.Map(), "task"), "intent") + if _, ok := intent["arguments"]; !ok { + t.Fatalf("expected stable intent payload to include arguments, got %+v", intent) + } + arguments := mapValue(intent, "arguments") + if len(arguments) != 0 { + t.Fatalf("expected empty intent arguments object, got %+v", arguments) + } +} diff --git a/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go b/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go new file mode 100644 index 000000000..258dd590e --- /dev/null +++ b/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go @@ -0,0 +1,25 @@ +package orchestrator + +func startTaskForTest(s *Service, request StartTaskRequest) (map[string]any, error) { + response, err := s.StartTask(request) + if err != nil { + return nil, err + } + return response.Map(), nil +} + +func submitInputForTest(s *Service, request SubmitInputRequest) (map[string]any, error) { + response, err := s.SubmitInput(request) + if err != nil { + return nil, err + } + return response.Map(), nil +} + +func taskDetailGetForTest(s *Service, request TaskDetailGetRequest) (map[string]any, error) { + response, err := s.TaskDetailGet(request) + if err != nil { + return nil, err + } + return response.Map(), nil +} diff --git a/services/local-service/internal/orchestrator/protocol_request_dto.go b/services/local-service/internal/orchestrator/protocol_request_dto.go new file mode 100644 index 000000000..9642f561e --- /dev/null +++ b/services/local-service/internal/orchestrator/protocol_request_dto.go @@ -0,0 +1,155 @@ +package orchestrator + +// RequestMeta carries the protocol trace anchor attached to stable RPC calls. +type RequestMeta struct { + TraceID string `json:"trace_id,omitempty"` + ClientTime string `json:"client_time,omitempty"` +} + +// PageContext describes the foreground page or host application that anchored +// a task entry request. +type PageContext struct { + Title string `json:"title,omitempty"` + AppName string `json:"app_name,omitempty"` + URL string `json:"url,omitempty"` + BrowserKind string `json:"browser_kind,omitempty"` + ProcessPath string `json:"process_path,omitempty"` + ProcessID int `json:"process_id,omitempty"` + WindowTitle string `json:"window_title,omitempty"` + VisibleText string `json:"visible_text,omitempty"` + HoverTarget string `json:"hover_target,omitempty"` +} + +// ScreenContext carries screen-derived signals used to infer controlled visual +// tasks without adding a separate public task object. +type ScreenContext struct { + Summary string `json:"summary,omitempty"` + ScreenSummary string `json:"screen_summary,omitempty"` + VisibleText string `json:"visible_text,omitempty"` + WindowTitle string `json:"window_title,omitempty"` + HoverTarget string `json:"hover_target,omitempty"` +} + +// BehaviorContext carries lightweight interaction signals that help route a +// task entry without becoming a business state machine. +type BehaviorContext struct { + LastAction string `json:"last_action,omitempty"` + DwellMillis int `json:"dwell_millis,omitempty"` + CopyCount int `json:"copy_count,omitempty"` + WindowSwitchCount int `json:"window_switch_count,omitempty"` + PageSwitchCount int `json:"page_switch_count,omitempty"` +} + +// SelectionContext carries selected text for task entry inference. +type SelectionContext struct { + Text string `json:"text,omitempty"` +} + +// ErrorContext carries foreground error text for task entry inference. +type ErrorContext struct { + Message string `json:"message,omitempty"` +} + +// ClipboardContext carries clipboard text for task entry inference. +type ClipboardContext struct { + Text string `json:"text,omitempty"` +} + +// InputContext is the stable context envelope shared by task entry methods. +// The orchestrator converts it back to its normalized capture payload before +// intent inference so context semantics stay centralized in the context service. +type InputContext struct { + Page *PageContext `json:"page,omitempty"` + Screen *ScreenContext `json:"screen,omitempty"` + Behavior *BehaviorContext `json:"behavior,omitempty"` + Selection *SelectionContext `json:"selection,omitempty"` + Error *ErrorContext `json:"error,omitempty"` + Clipboard *ClipboardContext `json:"clipboard,omitempty"` + Text string `json:"text,omitempty"` + SelectionText string `json:"selection_text,omitempty"` + Files []string `json:"files,omitempty"` + FilePaths []string `json:"file_paths,omitempty"` + ScreenSummary string `json:"screen_summary,omitempty"` + ClipboardText string `json:"clipboard_text,omitempty"` + HoverTarget string `json:"hover_target,omitempty"` + LastAction string `json:"last_action,omitempty"` + DwellMillis int `json:"dwell_millis,omitempty"` + CopyCount int `json:"copy_count,omitempty"` + WindowSwitchCount int `json:"window_switch_count,omitempty"` + PageSwitchCount int `json:"page_switch_count,omitempty"` +} + +// VoiceMeta carries voice-entry metadata for agent.input.submit. +type VoiceMeta struct { + VoiceSessionID string `json:"voice_session_id,omitempty"` + IsLockedSession bool `json:"is_locked_session,omitempty"` + ASRConfidence float64 `json:"asr_confidence,omitempty"` + SegmentID string `json:"segment_id,omitempty"` +} + +// InputSubmitInput is the formal input object for agent.input.submit. +type InputSubmitInput struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + InputMode string `json:"input_mode,omitempty"` +} + +// InputSubmitOptions carries execution preferences for agent.input.submit. +type InputSubmitOptions struct { + ConfirmRequired bool `json:"confirm_required,omitempty"` + PreferredDelivery string `json:"preferred_delivery,omitempty"` +} + +// SubmitInputRequest is the typed orchestrator boundary for agent.input.submit. +type SubmitInputRequest struct { + RequestMeta RequestMeta `json:"request_meta"` + SessionID string `json:"session_id,omitempty"` + Source string `json:"source,omitempty"` + Trigger string `json:"trigger,omitempty"` + Input InputSubmitInput `json:"input"` + Context *InputContext `json:"context,omitempty"` + VoiceMeta *VoiceMeta `json:"voice_meta,omitempty"` + Options *InputSubmitOptions `json:"options,omitempty"` +} + +// TaskStartInput is the formal input object for agent.task.start. +type TaskStartInput struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Files []string `json:"files,omitempty"` + PageContext *PageContext `json:"page_context,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// DeliveryPreference carries the formal delivery preference for task starts. +type DeliveryPreference struct { + Preferred string `json:"preferred,omitempty"` + Fallback string `json:"fallback,omitempty"` +} + +// TaskStartOptions carries task-start execution preferences. +type TaskStartOptions struct { + ConfirmRequired bool `json:"confirm_required,omitempty"` +} + +// StartTaskRequest is the typed orchestrator boundary for agent.task.start. +// Intent is an orchestrator-only testing and resume hook; the RPC decoder never +// admits an external intent field for agent.task.start. +type StartTaskRequest struct { + RequestMeta RequestMeta `json:"request_meta"` + SessionID string `json:"session_id,omitempty"` + Source string `json:"source,omitempty"` + Trigger string `json:"trigger,omitempty"` + Input TaskStartInput `json:"input"` + Context *InputContext `json:"context,omitempty"` + Delivery *DeliveryPreference `json:"delivery,omitempty"` + Options *TaskStartOptions `json:"options,omitempty"` + Intent map[string]any `json:"-"` +} + +// TaskDetailGetRequest is the typed orchestrator boundary for +// agent.task.detail.get. +type TaskDetailGetRequest struct { + RequestMeta RequestMeta `json:"request_meta"` + TaskID string `json:"task_id"` +} diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go new file mode 100644 index 000000000..f242840a4 --- /dev/null +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -0,0 +1,1381 @@ +package orchestrator + +import ( + "fmt" + "reflect" + "strings" +) + +// IntentPayload keeps intent arguments dynamic while making the outer protocol +// object explicit. +type IntentPayload struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +// TaskDTO is the protocol-facing task object returned through stable RPCs. +type TaskDTO struct { + TaskID string `json:"task_id"` + SessionID *string `json:"session_id"` + Title string `json:"title"` + SourceType string `json:"source_type"` + Status string `json:"status"` + Intent *IntentPayload `json:"intent"` + CurrentStep string `json:"current_step"` + RiskLevel string `json:"risk_level"` + LoopStopReason *string `json:"loop_stop_reason,omitempty"` + StartedAt *string `json:"started_at"` + UpdatedAt string `json:"updated_at"` + FinishedAt *string `json:"finished_at"` +} + +// BubbleMessageDTO is the stable bubble_message response object. +type BubbleMessageDTO struct { + BubbleID string `json:"bubble_id"` + TaskID string `json:"task_id"` + Type string `json:"type"` + Text string `json:"text"` + Pinned bool `json:"pinned"` + Hidden bool `json:"hidden"` + CreatedAt string `json:"created_at"` +} + +// DeliveryPayloadDTO is the stable payload nested under delivery_result. +type DeliveryPayloadDTO struct { + Path *string `json:"path"` + URL *string `json:"url"` + TaskID *string `json:"task_id"` +} + +// DeliveryResultDTO is the formal delivery_result object. +type DeliveryResultDTO struct { + Type string `json:"type"` + Title string `json:"title"` + Payload DeliveryPayloadDTO `json:"payload"` + PreviewText string `json:"preview_text"` +} + +// TaskStepDTO is the protocol-facing task_step timeline item. +type TaskStepDTO struct { + StepID string `json:"step_id"` + TaskID string `json:"task_id"` + Name string `json:"name"` + Status string `json:"status"` + OrderIndex int `json:"order_index"` + InputSummary string `json:"input_summary"` + OutputSummary string `json:"output_summary"` +} + +// ArtifactDTO is the formal artifact response object. +type ArtifactDTO struct { + ArtifactID string `json:"artifact_id"` + TaskID string `json:"task_id"` + ArtifactType string `json:"artifact_type"` + Title string `json:"title"` + Path string `json:"path"` + MimeType string `json:"mime_type"` +} + +// CitationDTO is the formal citation response object. +type CitationDTO struct { + CitationID string `json:"citation_id"` + TaskID string `json:"task_id"` + RunID string `json:"run_id"` + SourceType string `json:"source_type"` + SourceRef string `json:"source_ref"` + Label string `json:"label"` + ArtifactID string `json:"artifact_id,omitempty"` + ArtifactType string `json:"artifact_type,omitempty"` + EvidenceRole string `json:"evidence_role,omitempty"` + ExcerptText string `json:"excerpt_text,omitempty"` + ScreenSessionID string `json:"screen_session_id,omitempty"` +} + +// MirrorReferenceDTO is the mirror reference shape surfaced in task detail. +type MirrorReferenceDTO struct { + MemoryID string `json:"memory_id"` + Reason string `json:"reason"` + Summary string `json:"summary"` +} + +// ApprovalRequestDTO is the formal approval_request object. +type ApprovalRequestDTO struct { + ApprovalID string `json:"approval_id"` + TaskID string `json:"task_id"` + OperationName string `json:"operation_name"` + RiskLevel string `json:"risk_level"` + TargetObject string `json:"target_object"` + Reason string `json:"reason"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +// AuthorizationRecordDTO is the formal authorization_record object. +type AuthorizationRecordDTO struct { + AuthorizationRecordID string `json:"authorization_record_id"` + TaskID string `json:"task_id"` + ApprovalID string `json:"approval_id"` + Decision string `json:"decision"` + RememberRule bool `json:"remember_rule"` + Operator string `json:"operator"` + CreatedAt string `json:"created_at"` +} + +// AuditRecordDTO is the task-detail view of one formal audit record. +type AuditRecordDTO struct { + AuditID string `json:"audit_id"` + TaskID string `json:"task_id"` + Type string `json:"type"` + Action string `json:"action"` + Summary string `json:"summary"` + Target string `json:"target"` + Result string `json:"result"` + CreatedAt string `json:"created_at"` +} + +// RecoveryPointDTO is the task-detail security restore point object. +type RecoveryPointDTO struct { + RecoveryPointID string `json:"recovery_point_id"` + TaskID string `json:"task_id"` + Summary string `json:"summary"` + CreatedAt string `json:"created_at"` + Objects []string `json:"objects"` +} + +// SecuritySummaryDTO is the task-detail security summary projection. +type SecuritySummaryDTO struct { + SecurityStatus string `json:"security_status,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + PendingAuthorizations int `json:"pending_authorizations"` + LatestRestorePoint *RecoveryPointDTO `json:"latest_restore_point"` +} + +// TaskRuntimeSummaryDTO is the stable runtime summary returned from task detail. +type TaskRuntimeSummaryDTO struct { + LoopStopReason *string `json:"loop_stop_reason"` + EventsCount int `json:"events_count"` + LatestEventType *string `json:"latest_event_type"` + ActiveSteeringCount int `json:"active_steering_count"` + LatestFailureCode *string `json:"latest_failure_code"` + LatestFailureCategory *string `json:"latest_failure_category"` + LatestFailureSummary *string `json:"latest_failure_summary"` + ObservationSignals []string `json:"observation_signals"` +} + +// TaskEntryResponse is shared by agent.input.submit and agent.task.start. +type TaskEntryResponse struct { + Task *TaskDTO `json:"task"` + BubbleMessage *BubbleMessageDTO `json:"bubble_message"` + DeliveryResult *DeliveryResultDTO `json:"delivery_result"` +} + +// TaskDetailGetResponse is the typed result for agent.task.detail.get. +type TaskDetailGetResponse struct { + Task TaskDTO `json:"task"` + Timeline []TaskStepDTO `json:"timeline"` + DeliveryResult *DeliveryResultDTO `json:"delivery_result"` + Artifacts []ArtifactDTO `json:"artifacts"` + Citations []CitationDTO `json:"citations"` + MirrorReferences []MirrorReferenceDTO `json:"mirror_references"` + ApprovalRequest *ApprovalRequestDTO `json:"approval_request"` + AuthorizationRecord *AuthorizationRecordDTO `json:"authorization_record"` + AuditRecord *AuditRecordDTO `json:"audit_record"` + SecuritySummary SecuritySummaryDTO `json:"security_summary"` + RuntimeSummary TaskRuntimeSummaryDTO `json:"runtime_summary"` +} + +type startTaskProtocolParams struct { + RequestMeta RequestMeta `json:"request_meta"` + SessionID string `json:"session_id,omitempty"` + Source string `json:"source,omitempty"` + Trigger string `json:"trigger,omitempty"` + Input TaskStartInput `json:"input"` + Context *InputContext `json:"context,omitempty"` + Delivery *DeliveryPreference `json:"delivery,omitempty"` + Options *TaskStartOptions `json:"options,omitempty"` + Intent map[string]any `json:"intent,omitempty"` +} + +type submitInputProtocolParams struct { + RequestMeta RequestMeta `json:"request_meta"` + SessionID string `json:"session_id,omitempty"` + Source string `json:"source,omitempty"` + Trigger string `json:"trigger,omitempty"` + Input InputSubmitInput `json:"input"` + Context *InputContext `json:"context,omitempty"` + VoiceMeta *VoiceMeta `json:"voice_meta,omitempty"` + Options *InputSubmitOptions `json:"options,omitempty"` +} + +type taskDetailGetProtocolParams struct { + RequestMeta RequestMeta `json:"request_meta"` + TaskID string `json:"task_id"` +} + +// StartTaskRequestFromParams adapts RPC-decoded params to the typed +// orchestrator request. The adapter stays manual so hot RPC entrypoints avoid +// extra JSON round-trips after the boundary has already validated the payload. +func StartTaskRequestFromParams(params map[string]any) StartTaskRequest { + request := StartTaskRequest{ + RequestMeta: requestMetaFromMap(mapValue(params, "request_meta")), + SessionID: stringValue(params, "session_id", ""), + Source: stringValue(params, "source", ""), + Trigger: stringValue(params, "trigger", ""), + Input: taskStartInputFromMap(mapValue(params, "input")), + Context: inputContextPointerFromMap(mapValue(params, "context")), + Delivery: deliveryPreferencePointerFromMap(mapValue(params, "delivery")), + Options: taskStartOptionsPointerFromMap(mapValue(params, "options")), + } + if intent := mapValue(params, "intent"); len(intent) > 0 { + request.Intent = cloneMap(intent) + } + return request +} + +// SubmitInputRequestFromParams adapts RPC-decoded params to the typed +// orchestrator request. The adapter stays manual so hot RPC entrypoints avoid +// extra JSON round-trips after the boundary has already validated the payload. +func SubmitInputRequestFromParams(params map[string]any) SubmitInputRequest { + return SubmitInputRequest{ + RequestMeta: requestMetaFromMap(mapValue(params, "request_meta")), + SessionID: stringValue(params, "session_id", ""), + Source: stringValue(params, "source", ""), + Trigger: stringValue(params, "trigger", ""), + Input: inputSubmitInputFromMap(mapValue(params, "input")), + Context: inputContextPointerFromMap(mapValue(params, "context")), + VoiceMeta: voiceMetaPointerFromMap(mapValue(params, "voice_meta")), + Options: inputSubmitOptionsPointerFromMap(mapValue(params, "options")), + } +} + +// TaskDetailGetRequestFromParams adapts RPC-decoded params to the typed +// orchestrator request. The adapter stays manual so hot RPC entrypoints avoid +// extra JSON round-trips after the boundary has already validated the payload. +func TaskDetailGetRequestFromParams(params map[string]any) TaskDetailGetRequest { + return TaskDetailGetRequest{ + RequestMeta: requestMetaFromMap(mapValue(params, "request_meta")), + TaskID: stringValue(params, "task_id", ""), + } +} + +// ProtocolParamsMap exports the normalized protocol payload for RPC adapters +// that have already validated the transport envelope. +func (r StartTaskRequest) ProtocolParamsMap() map[string]any { + return protocolMapFromDTO(startTaskProtocolParams{ + RequestMeta: r.RequestMeta, + SessionID: strings.TrimSpace(r.SessionID), + Source: strings.TrimSpace(r.Source), + Trigger: strings.TrimSpace(r.Trigger), + Input: r.Input, + Context: r.Context, + Delivery: r.Delivery, + Options: r.Options, + Intent: cloneMap(r.Intent), + }) +} + +// ProtocolParamsMap exports the normalized protocol payload for RPC adapters +// that have already validated the transport envelope. +func (r SubmitInputRequest) ProtocolParamsMap() map[string]any { + return protocolMapFromDTO(submitInputProtocolParams{ + RequestMeta: r.RequestMeta, + SessionID: strings.TrimSpace(r.SessionID), + Source: strings.TrimSpace(r.Source), + Trigger: strings.TrimSpace(r.Trigger), + Input: r.Input, + Context: r.Context, + VoiceMeta: r.VoiceMeta, + Options: r.Options, + }) +} + +// ProtocolParamsMap exports the normalized protocol payload for RPC adapters +// that have already validated the transport envelope. +func (r TaskDetailGetRequest) ProtocolParamsMap() map[string]any { + return protocolMapFromDTO(taskDetailGetProtocolParams{ + RequestMeta: r.RequestMeta, + TaskID: strings.TrimSpace(r.TaskID), + }) +} + +func newTaskEntryResponse(payload map[string]any) (TaskEntryResponse, error) { + task, err := taskDTOPointerFromMap(payload, "task") + if err != nil { + return TaskEntryResponse{}, err + } + bubbleMessage, err := bubbleMessageDTOPointerFromMap(payload, "bubble_message") + if err != nil { + return TaskEntryResponse{}, err + } + deliveryResult, err := deliveryResultDTOPointerFromMap(payload, "delivery_result") + if err != nil { + return TaskEntryResponse{}, err + } + return TaskEntryResponse{ + Task: task, + BubbleMessage: bubbleMessage, + DeliveryResult: deliveryResult, + }, nil +} + +// Map returns the protocol payload as a map for package tests that assert +// individual fields. Production callers should consume the typed DTO directly. +func (r TaskEntryResponse) Map() map[string]any { + return protocolMapFromDTO(r) +} + +// Map returns the protocol payload as a map for package tests that assert +// individual fields. Production callers should consume the typed DTO directly. +func (r TaskDetailGetResponse) Map() map[string]any { + return protocolMapFromDTO(r) +} + +func taskDTOPointerFromMap(values map[string]any, key string) (*TaskDTO, error) { + payload, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + task, err := taskDTOFromMap(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + return &task, nil +} + +func taskDTOFromMap(values map[string]any) (TaskDTO, error) { + taskID, err := requireProtocolStringField(values, "task_id") + if err != nil { + return TaskDTO{}, err + } + sessionID, err := protocolStringPointerField(values, "session_id") + if err != nil { + return TaskDTO{}, err + } + title, err := requireProtocolStringField(values, "title") + if err != nil { + return TaskDTO{}, err + } + sourceType, err := requireProtocolStringField(values, "source_type") + if err != nil { + return TaskDTO{}, err + } + status, err := requireProtocolStringField(values, "status") + if err != nil { + return TaskDTO{}, err + } + intent, err := intentPayloadPointerFromMap(values, "intent") + if err != nil { + return TaskDTO{}, err + } + currentStep, err := requireProtocolStringField(values, "current_step") + if err != nil { + return TaskDTO{}, err + } + riskLevel, err := requireProtocolStringField(values, "risk_level") + if err != nil { + return TaskDTO{}, err + } + loopStopReason, err := protocolStringPointerField(values, "loop_stop_reason") + if err != nil { + return TaskDTO{}, err + } + startedAt, err := protocolStringPointerField(values, "started_at") + if err != nil { + return TaskDTO{}, err + } + updatedAt, err := requireProtocolStringField(values, "updated_at") + if err != nil { + return TaskDTO{}, err + } + finishedAt, err := protocolStringPointerField(values, "finished_at") + if err != nil { + return TaskDTO{}, err + } + return TaskDTO{ + TaskID: taskID, + SessionID: sessionID, + Title: title, + SourceType: sourceType, + Status: status, + Intent: intent, + CurrentStep: currentStep, + RiskLevel: riskLevel, + LoopStopReason: loopStopReason, + StartedAt: startedAt, + UpdatedAt: updatedAt, + FinishedAt: finishedAt, + }, nil +} + +func intentPayloadPointerFromMap(values map[string]any, key string) (*IntentPayload, error) { + payload, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + name, err := protocolStringField(payload, "name") + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + arguments, ok, err := protocolMapField(payload, "arguments") + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + if !ok { + arguments = map[string]any{} + } else { + arguments = cloneProtocolMap(arguments) + } + if strings.TrimSpace(name) == "" && len(arguments) == 0 { + return nil, nil + } + return &IntentPayload{Name: name, Arguments: arguments}, nil +} + +func bubbleMessageDTOPointerFromMap(values map[string]any, key string) (*BubbleMessageDTO, error) { + payload, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + bubbleMessage, err := bubbleMessageDTOFromMap(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + return &bubbleMessage, nil +} + +func bubbleMessageDTOFromMap(values map[string]any) (BubbleMessageDTO, error) { + bubbleID, err := requireProtocolStringField(values, "bubble_id") + if err != nil { + return BubbleMessageDTO{}, err + } + taskID, err := requireProtocolStringField(values, "task_id") + if err != nil { + return BubbleMessageDTO{}, err + } + messageType, err := requireProtocolStringField(values, "type") + if err != nil { + return BubbleMessageDTO{}, err + } + text, err := requireProtocolStringField(values, "text") + if err != nil { + return BubbleMessageDTO{}, err + } + pinned, err := requireProtocolBoolField(values, "pinned") + if err != nil { + return BubbleMessageDTO{}, err + } + hidden, err := requireProtocolBoolField(values, "hidden") + if err != nil { + return BubbleMessageDTO{}, err + } + createdAt, err := requireProtocolStringField(values, "created_at") + if err != nil { + return BubbleMessageDTO{}, err + } + return BubbleMessageDTO{ + BubbleID: bubbleID, + TaskID: taskID, + Type: messageType, + Text: text, + Pinned: pinned, + Hidden: hidden, + CreatedAt: createdAt, + }, nil +} + +func deliveryResultDTOPointerFromMap(values map[string]any, key string) (*DeliveryResultDTO, error) { + payload, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + deliveryResult, err := deliveryResultDTOFromMap(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + return &deliveryResult, nil +} + +func deliveryResultDTOFromMap(values map[string]any) (DeliveryResultDTO, error) { + resultType, err := requireProtocolStringField(values, "type") + if err != nil { + return DeliveryResultDTO{}, err + } + title, err := requireProtocolStringField(values, "title") + if err != nil { + return DeliveryResultDTO{}, err + } + payloadMap, err := requireProtocolMapField(values, "payload") + if err != nil { + return DeliveryResultDTO{}, err + } + payload, err := deliveryPayloadDTOFromMap(payloadMap) + if err != nil { + return DeliveryResultDTO{}, fmt.Errorf("payload: %w", err) + } + previewText, err := requireProtocolStringField(values, "preview_text") + if err != nil { + return DeliveryResultDTO{}, err + } + return DeliveryResultDTO{ + Type: resultType, + Title: title, + Payload: payload, + PreviewText: previewText, + }, nil +} + +func deliveryPayloadDTOFromMap(values map[string]any) (DeliveryPayloadDTO, error) { + path, err := protocolStringPointerField(values, "path") + if err != nil { + return DeliveryPayloadDTO{}, err + } + url, err := protocolStringPointerField(values, "url") + if err != nil { + return DeliveryPayloadDTO{}, err + } + taskID, err := protocolStringPointerField(values, "task_id") + if err != nil { + return DeliveryPayloadDTO{}, err + } + return DeliveryPayloadDTO{Path: path, URL: url, TaskID: taskID}, nil +} + +func artifactDTOFromMap(values map[string]any) (ArtifactDTO, error) { + artifactID, err := requireProtocolStringField(values, "artifact_id") + if err != nil { + return ArtifactDTO{}, err + } + taskID, err := requireProtocolStringField(values, "task_id") + if err != nil { + return ArtifactDTO{}, err + } + artifactType, err := requireProtocolStringField(values, "artifact_type") + if err != nil { + return ArtifactDTO{}, err + } + title, err := requireProtocolStringField(values, "title") + if err != nil { + return ArtifactDTO{}, err + } + path, err := requireProtocolStringField(values, "path") + if err != nil { + return ArtifactDTO{}, err + } + mimeType, err := requireProtocolStringField(values, "mime_type") + if err != nil { + return ArtifactDTO{}, err + } + return ArtifactDTO{ + ArtifactID: artifactID, + TaskID: taskID, + ArtifactType: artifactType, + Title: title, + Path: path, + MimeType: mimeType, + }, nil +} + +func citationDTOFromMap(values map[string]any) (CitationDTO, error) { + citationID, err := requireProtocolStringField(values, "citation_id") + if err != nil { + return CitationDTO{}, err + } + taskID, err := requireProtocolStringField(values, "task_id") + if err != nil { + return CitationDTO{}, err + } + runID, err := requireProtocolStringField(values, "run_id") + if err != nil { + return CitationDTO{}, err + } + sourceType, err := requireProtocolStringField(values, "source_type") + if err != nil { + return CitationDTO{}, err + } + sourceRef, err := requireProtocolStringField(values, "source_ref") + if err != nil { + return CitationDTO{}, err + } + label, err := requireProtocolStringField(values, "label") + if err != nil { + return CitationDTO{}, err + } + artifactID, err := protocolStringField(values, "artifact_id") + if err != nil { + return CitationDTO{}, err + } + artifactType, err := protocolStringField(values, "artifact_type") + if err != nil { + return CitationDTO{}, err + } + evidenceRole, err := protocolStringField(values, "evidence_role") + if err != nil { + return CitationDTO{}, err + } + excerptText, err := protocolStringField(values, "excerpt_text") + if err != nil { + return CitationDTO{}, err + } + screenSessionID, err := protocolStringField(values, "screen_session_id") + if err != nil { + return CitationDTO{}, err + } + return CitationDTO{ + CitationID: citationID, + TaskID: taskID, + RunID: runID, + SourceType: sourceType, + SourceRef: sourceRef, + Label: label, + ArtifactID: artifactID, + ArtifactType: artifactType, + EvidenceRole: evidenceRole, + ExcerptText: excerptText, + ScreenSessionID: screenSessionID, + }, nil +} + +func mirrorReferenceDTOFromMap(values map[string]any) (MirrorReferenceDTO, error) { + memoryID, err := requireProtocolStringField(values, "memory_id") + if err != nil { + return MirrorReferenceDTO{}, err + } + reason, err := requireProtocolStringField(values, "reason") + if err != nil { + return MirrorReferenceDTO{}, err + } + summary, err := requireProtocolStringField(values, "summary") + if err != nil { + return MirrorReferenceDTO{}, err + } + return MirrorReferenceDTO{MemoryID: memoryID, Reason: reason, Summary: summary}, nil +} + +func approvalRequestDTOFromMap(values map[string]any) (ApprovalRequestDTO, error) { + approvalID, err := requireProtocolStringField(values, "approval_id") + if err != nil { + return ApprovalRequestDTO{}, err + } + taskID, err := requireProtocolStringField(values, "task_id") + if err != nil { + return ApprovalRequestDTO{}, err + } + operationName, err := requireProtocolStringField(values, "operation_name") + if err != nil { + return ApprovalRequestDTO{}, err + } + riskLevel, err := requireProtocolStringField(values, "risk_level") + if err != nil { + return ApprovalRequestDTO{}, err + } + targetObject, err := requireProtocolStringField(values, "target_object") + if err != nil { + return ApprovalRequestDTO{}, err + } + reason, err := requireProtocolStringField(values, "reason") + if err != nil { + return ApprovalRequestDTO{}, err + } + status, err := requireProtocolStringField(values, "status") + if err != nil { + return ApprovalRequestDTO{}, err + } + createdAt, err := requireProtocolStringField(values, "created_at") + if err != nil { + return ApprovalRequestDTO{}, err + } + return ApprovalRequestDTO{ + ApprovalID: approvalID, + TaskID: taskID, + OperationName: operationName, + RiskLevel: riskLevel, + TargetObject: targetObject, + Reason: reason, + Status: status, + CreatedAt: createdAt, + }, nil +} + +func authorizationRecordDTOFromMap(values map[string]any) (AuthorizationRecordDTO, error) { + recordID, err := requireProtocolStringField(values, "authorization_record_id") + if err != nil { + return AuthorizationRecordDTO{}, err + } + taskID, err := requireProtocolStringField(values, "task_id") + if err != nil { + return AuthorizationRecordDTO{}, err + } + approvalID, err := requireProtocolStringField(values, "approval_id") + if err != nil { + return AuthorizationRecordDTO{}, err + } + decision, err := requireProtocolStringField(values, "decision") + if err != nil { + return AuthorizationRecordDTO{}, err + } + rememberRule, err := requireProtocolBoolField(values, "remember_rule") + if err != nil { + return AuthorizationRecordDTO{}, err + } + operator, err := requireProtocolStringField(values, "operator") + if err != nil { + return AuthorizationRecordDTO{}, err + } + createdAt, err := requireProtocolStringField(values, "created_at") + if err != nil { + return AuthorizationRecordDTO{}, err + } + return AuthorizationRecordDTO{ + AuthorizationRecordID: recordID, + TaskID: taskID, + ApprovalID: approvalID, + Decision: decision, + RememberRule: rememberRule, + Operator: operator, + CreatedAt: createdAt, + }, nil +} + +func auditRecordDTOFromMap(values map[string]any) (AuditRecordDTO, error) { + auditID, err := requireProtocolStringField(values, "audit_id") + if err != nil { + return AuditRecordDTO{}, err + } + taskID, err := requireProtocolStringField(values, "task_id") + if err != nil { + return AuditRecordDTO{}, err + } + recordType, err := requireProtocolStringField(values, "type") + if err != nil { + return AuditRecordDTO{}, err + } + action, err := requireProtocolStringField(values, "action") + if err != nil { + return AuditRecordDTO{}, err + } + summary, err := requireProtocolStringField(values, "summary") + if err != nil { + return AuditRecordDTO{}, err + } + target, err := requireProtocolStringField(values, "target") + if err != nil { + return AuditRecordDTO{}, err + } + result, err := requireProtocolStringField(values, "result") + if err != nil { + return AuditRecordDTO{}, err + } + createdAt, err := requireProtocolStringField(values, "created_at") + if err != nil { + return AuditRecordDTO{}, err + } + return AuditRecordDTO{ + AuditID: auditID, + TaskID: taskID, + Type: recordType, + Action: action, + Summary: summary, + Target: target, + Result: result, + CreatedAt: createdAt, + }, nil +} + +func recoveryPointDTOPointerFromMap(values map[string]any, key string) (*RecoveryPointDTO, error) { + payload, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + recoveryPoint, err := recoveryPointDTOFromMap(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + return &recoveryPoint, nil +} + +func recoveryPointDTOFromMap(values map[string]any) (RecoveryPointDTO, error) { + recoveryPointID, err := requireProtocolStringField(values, "recovery_point_id") + if err != nil { + return RecoveryPointDTO{}, err + } + taskID, err := requireProtocolStringField(values, "task_id") + if err != nil { + return RecoveryPointDTO{}, err + } + summary, err := requireProtocolStringField(values, "summary") + if err != nil { + return RecoveryPointDTO{}, err + } + createdAt, err := requireProtocolStringField(values, "created_at") + if err != nil { + return RecoveryPointDTO{}, err + } + objects, err := requireProtocolStringSliceField(values, "objects") + if err != nil { + return RecoveryPointDTO{}, err + } + return RecoveryPointDTO{ + RecoveryPointID: recoveryPointID, + TaskID: taskID, + Summary: summary, + CreatedAt: createdAt, + Objects: objects, + }, nil +} + +func securitySummaryDTOFromMap(values map[string]any) (SecuritySummaryDTO, error) { + securityStatus, err := requireProtocolStringField(values, "security_status") + if err != nil { + return SecuritySummaryDTO{}, err + } + riskLevel, err := requireProtocolStringField(values, "risk_level") + if err != nil { + return SecuritySummaryDTO{}, err + } + pendingAuthorizations, err := requireProtocolIntField(values, "pending_authorizations") + if err != nil { + return SecuritySummaryDTO{}, err + } + latestRestorePoint, err := recoveryPointDTOPointerFromMap(values, "latest_restore_point") + if err != nil { + return SecuritySummaryDTO{}, err + } + return SecuritySummaryDTO{ + SecurityStatus: securityStatus, + RiskLevel: riskLevel, + PendingAuthorizations: pendingAuthorizations, + LatestRestorePoint: latestRestorePoint, + }, nil +} + +func runtimeSummaryDTOFromMap(values map[string]any) (TaskRuntimeSummaryDTO, error) { + loopStopReason, err := protocolStringPointerField(values, "loop_stop_reason") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + eventsCount, err := requireProtocolIntField(values, "events_count") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + latestEventType, err := protocolStringPointerField(values, "latest_event_type") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + activeSteeringCount, err := requireProtocolIntField(values, "active_steering_count") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + latestFailureCode, err := protocolStringPointerField(values, "latest_failure_code") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + latestFailureCategory, err := protocolStringPointerField(values, "latest_failure_category") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + latestFailureSummary, err := protocolStringPointerField(values, "latest_failure_summary") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + observationSignals, err := requireProtocolStringSliceField(values, "observation_signals") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + return TaskRuntimeSummaryDTO{ + LoopStopReason: loopStopReason, + EventsCount: eventsCount, + LatestEventType: latestEventType, + ActiveSteeringCount: activeSteeringCount, + LatestFailureCode: latestFailureCode, + LatestFailureCategory: latestFailureCategory, + LatestFailureSummary: latestFailureSummary, + ObservationSignals: observationSignals, + }, nil +} + +func protocolMapField(values map[string]any, key string) (map[string]any, bool, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return nil, false, nil + } + value, ok := rawValue.(map[string]any) + if !ok { + return nil, false, protocolTypeError(key, "object", rawValue) + } + return value, true, nil +} + +func requireProtocolMapField(values map[string]any, key string) (map[string]any, error) { + value, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("%s must be object", key) + } + return value, nil +} + +func protocolStringField(values map[string]any, key string) (string, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return "", nil + } + value, ok := rawValue.(string) + if !ok { + return "", protocolTypeError(key, "string", rawValue) + } + return value, nil +} + +func requireProtocolStringField(values map[string]any, key string) (string, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return "", fmt.Errorf("%s must be string", key) + } + value, ok := rawValue.(string) + if !ok { + return "", protocolTypeError(key, "string", rawValue) + } + return value, nil +} + +func protocolStringPointerField(values map[string]any, key string) (*string, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return nil, nil + } + value, ok := rawValue.(string) + if !ok { + return nil, protocolTypeError(key, "string or null", rawValue) + } + return &value, nil +} + +func protocolBoolField(values map[string]any, key string) (bool, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return false, nil + } + value, ok := rawValue.(bool) + if !ok { + return false, protocolTypeError(key, "boolean", rawValue) + } + return value, nil +} + +func requireProtocolBoolField(values map[string]any, key string) (bool, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return false, fmt.Errorf("%s must be boolean", key) + } + return protocolBoolField(values, key) +} + +func protocolIntField(values map[string]any, key string) (int, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return 0, nil + } + switch value := rawValue.(type) { + case int: + return value, nil + case int32: + return int(value), nil + case int64: + return int(value), nil + case float32: + return int(value), nil + case float64: + return int(value), nil + default: + return 0, protocolTypeError(key, "number", rawValue) + } +} + +func requireProtocolIntField(values map[string]any, key string) (int, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return 0, fmt.Errorf("%s must be number", key) + } + return protocolIntField(values, key) +} + +func protocolStringSliceField(values map[string]any, key string) ([]string, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return nil, nil + } + switch value := rawValue.(type) { + case []string: + return append([]string(nil), value...), nil + case []any: + result := make([]string, 0, len(value)) + for index, rawItem := range value { + item, ok := rawItem.(string) + if !ok { + return nil, protocolIndexedTypeError(key, index, "string", rawItem) + } + result = append(result, item) + } + return result, nil + default: + return nil, protocolTypeError(key, "array of strings", rawValue) + } +} + +func requireProtocolStringSliceField(values map[string]any, key string) ([]string, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return nil, fmt.Errorf("%s must be array of strings", key) + } + return protocolStringSliceField(values, key) +} + +func cloneProtocolMap(values map[string]any) map[string]any { + if values == nil { + return nil + } + cloned := cloneMap(values) + if cloned == nil { + return map[string]any{} + } + return cloned +} + +func protocolTypeError(key, expected string, rawValue any) error { + return fmt.Errorf("%s must be %s, got %T", key, expected, rawValue) +} + +func protocolIndexedTypeError(key string, index int, expected string, rawValue any) error { + return fmt.Errorf("%s[%d] must be %s, got %T", key, index, expected, rawValue) +} + +func requestMetaFromMap(values map[string]any) RequestMeta { + return RequestMeta{ + TraceID: stringValue(values, "trace_id", ""), + ClientTime: stringValue(values, "client_time", ""), + } +} + +func pageContextPointerFromMap(values map[string]any) *PageContext { + if len(values) == 0 { + return nil + } + return &PageContext{ + Title: stringValue(values, "title", ""), + AppName: stringValue(values, "app_name", ""), + URL: stringValue(values, "url", ""), + BrowserKind: stringValue(values, "browser_kind", ""), + ProcessPath: stringValue(values, "process_path", ""), + ProcessID: intValue(values, "process_id", 0), + WindowTitle: stringValue(values, "window_title", ""), + VisibleText: stringValue(values, "visible_text", ""), + HoverTarget: stringValue(values, "hover_target", ""), + } +} + +func screenContextPointerFromMap(values map[string]any) *ScreenContext { + if len(values) == 0 { + return nil + } + return &ScreenContext{ + Summary: stringValue(values, "summary", ""), + ScreenSummary: stringValue(values, "screen_summary", ""), + VisibleText: stringValue(values, "visible_text", ""), + WindowTitle: stringValue(values, "window_title", ""), + HoverTarget: stringValue(values, "hover_target", ""), + } +} + +func behaviorContextPointerFromMap(values map[string]any) *BehaviorContext { + if len(values) == 0 { + return nil + } + return &BehaviorContext{ + LastAction: stringValue(values, "last_action", ""), + DwellMillis: intValue(values, "dwell_millis", 0), + CopyCount: intValue(values, "copy_count", 0), + WindowSwitchCount: intValue(values, "window_switch_count", 0), + PageSwitchCount: intValue(values, "page_switch_count", 0), + } +} + +func selectionContextPointerFromMap(values map[string]any) *SelectionContext { + if len(values) == 0 { + return nil + } + return &SelectionContext{Text: stringValue(values, "text", "")} +} + +func errorContextPointerFromMap(values map[string]any) *ErrorContext { + if len(values) == 0 { + return nil + } + return &ErrorContext{Message: stringValue(values, "message", "")} +} + +func clipboardContextPointerFromMap(values map[string]any) *ClipboardContext { + if len(values) == 0 { + return nil + } + return &ClipboardContext{Text: stringValue(values, "text", "")} +} + +func inputContextPointerFromMap(values map[string]any) *InputContext { + if len(values) == 0 { + return nil + } + return &InputContext{ + Page: pageContextPointerFromMap(mapValue(values, "page")), + Screen: screenContextPointerFromMap(mapValue(values, "screen")), + Behavior: behaviorContextPointerFromMap(mapValue(values, "behavior")), + Selection: selectionContextPointerFromMap(mapValue(values, "selection")), + Error: errorContextPointerFromMap(mapValue(values, "error")), + Clipboard: clipboardContextPointerFromMap(mapValue(values, "clipboard")), + Text: stringValue(values, "text", ""), + SelectionText: stringValue(values, "selection_text", ""), + Files: stringSliceValue(values["files"]), + FilePaths: stringSliceValue(values["file_paths"]), + ScreenSummary: stringValue(values, "screen_summary", ""), + ClipboardText: stringValue(values, "clipboard_text", ""), + HoverTarget: stringValue(values, "hover_target", ""), + LastAction: stringValue(values, "last_action", ""), + DwellMillis: intValue(values, "dwell_millis", 0), + CopyCount: intValue(values, "copy_count", 0), + WindowSwitchCount: intValue(values, "window_switch_count", 0), + PageSwitchCount: intValue(values, "page_switch_count", 0), + } +} + +func voiceMetaPointerFromMap(values map[string]any) *VoiceMeta { + if len(values) == 0 { + return nil + } + return &VoiceMeta{ + VoiceSessionID: stringValue(values, "voice_session_id", ""), + IsLockedSession: boolValue(values, "is_locked_session", false), + ASRConfidence: floatValue(values, "asr_confidence", 0), + SegmentID: stringValue(values, "segment_id", ""), + } +} + +func inputSubmitInputFromMap(values map[string]any) InputSubmitInput { + return InputSubmitInput{ + Type: stringValue(values, "type", ""), + Text: stringValue(values, "text", ""), + InputMode: stringValue(values, "input_mode", ""), + } +} + +func inputSubmitOptionsPointerFromMap(values map[string]any) *InputSubmitOptions { + if len(values) == 0 { + return nil + } + return &InputSubmitOptions{ + ConfirmRequired: boolValue(values, "confirm_required", false), + PreferredDelivery: stringValue(values, "preferred_delivery", ""), + } +} + +func taskStartInputFromMap(values map[string]any) TaskStartInput { + return TaskStartInput{ + Type: stringValue(values, "type", ""), + Text: stringValue(values, "text", ""), + Files: stringSliceValue(values["files"]), + PageContext: pageContextPointerFromMap(mapValue(values, "page_context")), + ErrorMessage: stringValue(values, "error_message", ""), + } +} + +func deliveryPreferencePointerFromMap(values map[string]any) *DeliveryPreference { + if len(values) == 0 { + return nil + } + return &DeliveryPreference{ + Preferred: stringValue(values, "preferred", ""), + Fallback: stringValue(values, "fallback", ""), + } +} + +func taskStartOptionsPointerFromMap(values map[string]any) *TaskStartOptions { + if len(values) == 0 { + return nil + } + return &TaskStartOptions{ + ConfirmRequired: boolValue(values, "confirm_required", false), + } +} + +func floatValue(values map[string]any, key string, fallback float64) float64 { + rawValue, ok := values[key] + if !ok { + return fallback + } + switch value := rawValue.(type) { + case float64: + return value + case float32: + return float64(value) + case int: + return float64(value) + case int32: + return float64(value) + case int64: + return float64(value) + default: + return fallback + } +} + +func protocolMapFromDTO(value any) map[string]any { + result, ok := protocolValueFromReflect(reflect.ValueOf(value)).(map[string]any) + if !ok || result == nil { + return map[string]any{} + } + return result +} + +func protocolValueFromReflect(value reflect.Value) any { + if !value.IsValid() { + return nil + } + for value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface { + if value.IsNil() { + return nil + } + value = value.Elem() + } + + switch value.Kind() { + case reflect.Struct: + result := map[string]any{} + valueType := value.Type() + for index := 0; index < value.NumField(); index++ { + field := valueType.Field(index) + if !field.IsExported() { + continue + } + name, omitEmpty := jsonFieldName(field) + if name == "" { + continue + } + fieldValue := value.Field(index) + if omitEmpty && isJSONEmptyValue(fieldValue) { + continue + } + result[name] = protocolValueFromReflect(fieldValue) + } + return result + case reflect.Slice, reflect.Array: + return protocolSliceValue(value) + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return nil + } + if value.IsNil() { + return map[string]any(nil) + } + result := make(map[string]any, value.Len()) + iter := value.MapRange() + for iter.Next() { + result[iter.Key().String()] = protocolValueFromReflect(iter.Value()) + } + return result + default: + return value.Interface() + } +} + +func protocolSliceValue(value reflect.Value) any { + length := value.Len() + elemKind := value.Type().Elem().Kind() + switch elemKind { + case reflect.Struct, reflect.Map: + result := make([]map[string]any, 0, length) + for index := 0; index < length; index++ { + item, ok := protocolValueFromReflect(value.Index(index)).(map[string]any) + if !ok { + return protocolSliceFallback(value) + } + result = append(result, item) + } + return result + case reflect.String: + result := make([]string, 0, length) + for index := 0; index < length; index++ { + result = append(result, value.Index(index).String()) + } + return result + case reflect.Bool: + result := make([]bool, 0, length) + for index := 0; index < length; index++ { + result = append(result, value.Index(index).Bool()) + } + return result + default: + return protocolSliceFallback(value) + } +} + +func protocolSliceFallback(value reflect.Value) []any { + result := make([]any, 0, value.Len()) + for index := 0; index < value.Len(); index++ { + result = append(result, protocolValueFromReflect(value.Index(index))) + } + return result +} + +func jsonFieldName(field reflect.StructField) (string, bool) { + tag := field.Tag.Get("json") + if tag == "-" { + return "", false + } + if tag == "" { + return field.Name, false + } + parts := strings.Split(tag, ",") + name := parts[0] + if name == "" { + name = field.Name + } + for _, option := range parts[1:] { + if option == "omitempty" { + return name, true + } + } + return name, false +} + +func isJSONEmptyValue(value reflect.Value) bool { + switch value.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return value.Len() == 0 + case reflect.Bool: + return !value.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return value.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return value.Uint() == 0 + case reflect.Float32, reflect.Float64: + return value.Float() == 0 + case reflect.Interface, reflect.Pointer: + return value.IsNil() + } + return false +} diff --git a/services/local-service/internal/orchestrator/screen_analyze.go b/services/local-service/internal/orchestrator/screen_analyze.go index e3a6e500e..ffc5638eb 100644 --- a/services/local-service/internal/orchestrator/screen_analyze.go +++ b/services/local-service/internal/orchestrator/screen_analyze.go @@ -14,9 +14,31 @@ import ( "github.com/cialloclaw/cialloclaw/services/local-service/internal/tools" ) -func (s *Service) handleScreenAnalyzeStart(params map[string]any, snapshot taskcontext.TaskContextSnapshot, explicitIntent map[string]any) (map[string]any, bool, error) { +type screenIntentDTO struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +type emptyIntentArguments struct{} + +type screenAnalyzeCandidateIntentArguments struct { + TaskID string `json:"task_id"` + RunID string `json:"run_id"` + ScreenSessionID string `json:"screen_session_id"` + FrameID string `json:"frame_id"` + Path string `json:"path"` + CaptureMode string `json:"capture_mode"` + Source string `json:"source"` + CapturedAt string `json:"captured_at"` + RetentionPolicy string `json:"retention_policy"` + Language string `json:"language"` + EvidenceRole string `json:"evidence_role"` + TargetObject string `json:"target_object"` +} + +func (s *Service) handleScreenAnalyzeStart(params map[string]any, snapshot taskcontext.TaskContextSnapshot, explicitIntent map[string]any) (TaskEntryResponse, bool, error) { if stringValue(explicitIntent, "name", "") != "screen_analyze" || s.executor == nil || !s.executor.ScreenCapabilitySnapshot().Available { - return nil, false, nil + return TaskEntryResponse{}, false, nil } resolvedIntent := s.resolveScreenAnalyzeIntent(snapshot, explicitIntent) task := s.runEngine.CreateTask(runengine.CreateTaskInput{ @@ -35,35 +57,32 @@ func (s *Service) handleScreenAnalyzeStart(params map[string]any, snapshot taskc Snapshot: snapshot, }) if queuedTask, queueBubble, queued, queueErr := s.queueTaskIfSessionBusy(task); queueErr != nil { - return nil, false, queueErr + return TaskEntryResponse{}, false, queueErr } else if queued { - return map[string]any{ - "task": taskMap(queuedTask), - "bubble_message": queueBubble, - "delivery_result": nil, - }, true, nil + response, err := buildTaskEntryResponse(&queuedTask, queueBubble, nil) + return response, true, err } - approvalRequest, pendingExecution, bubble, err := s.buildScreenAnalysisApprovalState(task) + approvalState, err := s.buildScreenAnalysisApprovalState(task) if err != nil { - return nil, false, err + return TaskEntryResponse{}, false, err } + approvalRequest := approvalState.approvalRequestMap() + pendingExecution := approvalState.pendingExecutionMap() + bubble := approvalState.bubbleMessageMap() updatedTask, ok := s.runEngine.MarkWaitingApprovalWithPlan(task.TaskID, approvalRequest, pendingExecution, bubble) if !ok { - return nil, false, ErrTaskNotFound + return TaskEntryResponse{}, false, ErrTaskNotFound } - if err := s.persistApprovalRequestState(updatedTask.TaskID, approvalRequest, mapValue(pendingExecution, "impact_scope")); err != nil { - return nil, false, err + if err := s.persistApprovalRequestState(updatedTask.TaskID, approvalRequest, approvalState.PendingExecution.ImpactScope.mapValue()); err != nil { + return TaskEntryResponse{}, false, err } - return map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": bubble, - "delivery_result": nil, - }, true, nil + response, err := buildTaskEntryResponse(&updatedTask, bubble, nil) + return response, true, err } -func (s *Service) handleScreenAnalyzeSuggestion(params map[string]any, snapshot taskcontext.TaskContextSnapshot, suggestion intent.Suggestion) (map[string]any, bool, error) { +func (s *Service) handleScreenAnalyzeSuggestion(params map[string]any, snapshot taskcontext.TaskContextSnapshot, suggestion intent.Suggestion) (TaskEntryResponse, bool, error) { if stringValue(suggestion.Intent, "name", "") != "screen_analyze" || suggestion.RequiresConfirm { - return nil, false, nil + return TaskEntryResponse{}, false, nil } return s.handleScreenAnalyzeStart(params, snapshot, suggestion.Intent) } @@ -76,10 +95,7 @@ func (s *Service) normalizeSuggestedIntentForAvailability(snapshot taskcontext.T return suggestion } fallback := suggestion - fallback.Intent = map[string]any{ - "name": "agent_loop", - "arguments": map[string]any{}, - } + fallback.Intent = protocolIntentMap("agent_loop", emptyIntentArguments{}) fallback.IntentConfirmed = true // Preserve the caller's confirmation gate when screen-specific handling is // unavailable so the downgrade does not auto-execute a generic task. @@ -102,7 +118,7 @@ func inferredScreenFallbackSubject(snapshot taskcontext.TaskContextSnapshot) str // buildScreenAnalysisApprovalState reconstructs the controlled approval plan // from the task intent so queued resumes can re-enter the same authorization // path instead of falling through to the generic executor. -func (s *Service) buildScreenAnalysisApprovalState(task runengine.TaskRecord) (map[string]any, map[string]any, map[string]any, error) { +func (s *Service) buildScreenAnalysisApprovalState(task runengine.TaskRecord) (screenAnalysisApprovalState, error) { arguments := mapValue(task.Intent, "arguments") sourcePath := stringValue(arguments, "path", "") captureMode := screenCaptureModeForIntent(arguments) @@ -114,35 +130,35 @@ func (s *Service) buildScreenAnalysisApprovalState(task runengine.TaskRecord) (m RiskLevel: "yellow", Reason: "screen_capture_requires_authorization", }) - pendingExecution := map[string]any{ - "kind": "screen_analysis", - "operation_name": "screen_capture", - "source_path": sourcePath, - "capture_mode": string(captureMode), - "source": source, - "target_object": targetObject, - "language": firstNonEmptyString(stringValue(arguments, "language", ""), "eng"), - "evidence_role": firstNonEmptyString(stringValue(arguments, "evidence_role", ""), "error_evidence"), - "delivery_type": "bubble", - "result_title": presentation.Text(presentation.MessageResultTitleScreen, nil), - "preview_text": screenAnalysisPreviewText(captureMode), - "impact_scope": map[string]any{ - "files": impactFilesForScreenTarget(sourcePath), - "webpages": []string{}, - "apps": []string{}, - "out_of_workspace": false, - "overwrite_or_delete_risk": false, + pendingExecution := screenAnalysisPendingExecution{ + Kind: "screen_analysis", + OperationName: "screen_capture", + SourcePath: sourcePath, + CaptureMode: string(captureMode), + Source: source, + TargetObject: targetObject, + Language: firstNonEmptyString(stringValue(arguments, "language", ""), "eng"), + EvidenceRole: firstNonEmptyString(stringValue(arguments, "evidence_role", ""), "error_evidence"), + DeliveryType: "bubble", + ResultTitle: presentation.Text(presentation.MessageResultTitleScreen, nil), + PreviewText: screenAnalysisPreviewText(captureMode), + ImpactScope: screenAnalysisImpactScope{ + Files: impactFilesForScreenTarget(sourcePath), + Webpages: []string{}, + Apps: []string{}, + OutOfWorkspace: false, + OverwriteOrDeleteRisk: false, }, } bubble := s.delivery.BuildBubbleMessage(task.TaskID, "status", presentation.Text(presentation.MessageBubbleScreenApproval, nil), task.UpdatedAt.Format(dateTimeLayout)) - return approvalRequest, pendingExecution, bubble, nil + return newScreenAnalysisApprovalState(approvalRequest, pendingExecution, bubble) } func (s *Service) resolveScreenAnalyzeIntent(snapshot taskcontext.TaskContextSnapshot, current map[string]any) map[string]any { updatedIntent := cloneMap(current) arguments := cloneMap(mapValue(updatedIntent, "arguments")) if arguments == nil { - arguments = map[string]any{} + arguments = make(map[string]any) } if strings.TrimSpace(stringValue(arguments, "language", "")) == "" { arguments["language"] = "eng" @@ -274,7 +290,7 @@ func inferredScreenEvidenceRole(snapshot taskcontext.TaskContextSnapshot, argume func (s *Service) executeScreenAnalysisAfterApproval(task runengine.TaskRecord, pendingExecution map[string]any) (runengine.TaskRecord, map[string]any, map[string]any, error) { if s.executor == nil || s.executor.ScreenClient() == nil { - failedTask, failureBubble := s.failExecutionTask(task, map[string]any{"name": "screen_analyze"}, execution.Result{}, tools.ErrScreenCaptureNotSupported) + failedTask, failureBubble := s.failExecutionTask(task, protocolIntentMap("screen_analyze", nil), execution.Result{}, tools.ErrScreenCaptureNotSupported) return failedTask, failureBubble, nil, nil } screenClient := s.executor.ScreenClient() @@ -289,32 +305,29 @@ func (s *Service) executeScreenAnalysisAfterApproval(task runengine.TaskRecord, CaptureMode: captureMode, }) if err != nil { - failedTask, failureBubble := s.failExecutionTask(task, map[string]any{"name": "screen_analyze"}, execution.Result{}, err) + failedTask, failureBubble := s.failExecutionTask(task, protocolIntentMap("screen_analyze", nil), execution.Result{}, err) return failedTask, failureBubble, nil, nil } candidate, err := captureScreenCandidateAfterApproval(screenClient, screenSession.ScreenSessionID, task, pendingExecution, captureMode) if err != nil { expireAndCleanupScreenSession(screenClient, screenSession.ScreenSessionID, "capture_failed") - failedTask, failureBubble := s.failExecutionTask(task, map[string]any{"name": "screen_analyze"}, execution.Result{}, err) + failedTask, failureBubble := s.failExecutionTask(task, protocolIntentMap("screen_analyze", nil), execution.Result{}, err) return failedTask, failureBubble, nil, nil } - execIntent := map[string]any{ - "name": "screen_analyze_candidate", - "arguments": map[string]any{ - "task_id": task.TaskID, - "run_id": task.RunID, - "screen_session_id": screenSession.ScreenSessionID, - "frame_id": candidate.FrameID, - "path": candidate.Path, - "capture_mode": string(candidate.CaptureMode), - "source": candidate.Source, - "captured_at": candidate.CapturedAt.UTC().Format(time.RFC3339), - "retention_policy": string(candidate.RetentionPolicy), - "language": stringValue(pendingExecution, "language", "eng"), - "evidence_role": stringValue(pendingExecution, "evidence_role", "error_evidence"), - "target_object": stringValue(pendingExecution, "target_object", "current_screen"), - }, - } + execIntent := protocolIntentMap("screen_analyze_candidate", screenAnalyzeCandidateIntentArguments{ + TaskID: task.TaskID, + RunID: task.RunID, + ScreenSessionID: screenSession.ScreenSessionID, + FrameID: candidate.FrameID, + Path: candidate.Path, + CaptureMode: string(candidate.CaptureMode), + Source: candidate.Source, + CapturedAt: candidate.CapturedAt.UTC().Format(time.RFC3339), + RetentionPolicy: string(candidate.RetentionPolicy), + Language: stringValue(pendingExecution, "language", "eng"), + EvidenceRole: stringValue(pendingExecution, "evidence_role", "error_evidence"), + TargetObject: stringValue(pendingExecution, "target_object", "current_screen"), + }) updatedTask, bubble, deliveryResult, _, err := s.executeTask(task, snapshotFromTask(task), execIntent) if err != nil { expireAndCleanupScreenSession(screenClient, screenSession.ScreenSessionID, "analysis_failed") @@ -332,6 +345,17 @@ func (s *Service) executeScreenAnalysisAfterApproval(task runengine.TaskRecord, return updatedTask, bubble, deliveryResult, nil } +func protocolIntentMap(name string, arguments any) map[string]any { + intent := screenIntentDTO{ + Name: name, + Arguments: map[string]any{}, + } + if arguments != nil { + intent.Arguments = protocolMapFromDTO(arguments) + } + return protocolMapFromDTO(intent) +} + // captureScreenCandidateAfterApproval keeps the controlled screen entry on one // orchestrator path while still selecting the owner-5 capture primitive that // matches the approved screen analysis mode. diff --git a/services/local-service/internal/orchestrator/screen_approval_state.go b/services/local-service/internal/orchestrator/screen_approval_state.go new file mode 100644 index 000000000..3bfa37159 --- /dev/null +++ b/services/local-service/internal/orchestrator/screen_approval_state.go @@ -0,0 +1,77 @@ +package orchestrator + +// screenAnalysisApprovalState keeps the controlled screen authorization bundle +// typed until the runtime engine or storage boundary needs legacy map payloads. +type screenAnalysisApprovalState struct { + ApprovalRequest ApprovalRequestDTO + PendingExecution screenAnalysisPendingExecution + BubbleMessage BubbleMessageDTO +} + +type screenAnalysisPendingExecution struct { + Kind string `json:"kind"` + OperationName string `json:"operation_name"` + SourcePath string `json:"source_path"` + CaptureMode string `json:"capture_mode"` + Source string `json:"source"` + TargetObject string `json:"target_object"` + Language string `json:"language"` + EvidenceRole string `json:"evidence_role"` + DeliveryType string `json:"delivery_type"` + ResultTitle string `json:"result_title"` + PreviewText string `json:"preview_text"` + ImpactScope screenAnalysisImpactScope `json:"impact_scope"` +} + +type screenAnalysisImpactScope struct { + Files []string `json:"files"` + Webpages []string `json:"webpages"` + Apps []string `json:"apps"` + OutOfWorkspace bool `json:"out_of_workspace"` + OverwriteOrDeleteRisk bool `json:"overwrite_or_delete_risk"` +} + +func newScreenAnalysisApprovalState(approvalRequest map[string]any, pendingExecution screenAnalysisPendingExecution, bubble map[string]any) (screenAnalysisApprovalState, error) { + approval, err := approvalRequestDTOFromMap(approvalRequest) + if err != nil { + return screenAnalysisApprovalState{}, err + } + bubbleMessage, err := bubbleMessageDTOFromMap(bubble) + if err != nil { + return screenAnalysisApprovalState{}, err + } + return screenAnalysisApprovalState{ + ApprovalRequest: approval, + PendingExecution: pendingExecution, + BubbleMessage: bubbleMessage, + }, nil +} + +func (state screenAnalysisApprovalState) approvalRequestMap() map[string]any { + return protocolMapFromDTO(state.ApprovalRequest) +} + +func (state screenAnalysisApprovalState) pendingExecutionMap() map[string]any { + return protocolMapFromDTO(state.PendingExecution) +} + +func (state screenAnalysisApprovalState) bubbleMessageMap() map[string]any { + return protocolMapFromDTO(state.BubbleMessage) +} + +func (scope screenAnalysisImpactScope) mapValue() map[string]any { + return protocolMapFromDTO(screenAnalysisImpactScope{ + Files: cloneScreenAnalysisStrings(scope.Files), + Webpages: cloneScreenAnalysisStrings(scope.Webpages), + Apps: cloneScreenAnalysisStrings(scope.Apps), + OutOfWorkspace: scope.OutOfWorkspace, + OverwriteOrDeleteRisk: scope.OverwriteOrDeleteRisk, + }) +} + +func cloneScreenAnalysisStrings(values []string) []string { + if len(values) == 0 { + return []string{} + } + return append([]string(nil), values...) +} diff --git a/services/local-service/internal/orchestrator/screen_approval_state_test.go b/services/local-service/internal/orchestrator/screen_approval_state_test.go new file mode 100644 index 000000000..cddf1e257 --- /dev/null +++ b/services/local-service/internal/orchestrator/screen_approval_state_test.go @@ -0,0 +1,87 @@ +package orchestrator + +import ( + "reflect" + "testing" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/runengine" +) + +func TestBuildScreenAnalysisApprovalStateTypedGolden(t *testing.T) { + service := newTestService() + task := service.runEngine.CreateTask(runengine.CreateTaskInput{ + SessionID: "sess_screen_state", + Title: "Analyze current screen", + SourceType: "screen_capture", + Status: "waiting_auth", + CurrentStep: "waiting_authorization", + RiskLevel: "yellow", + Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "inputs/screen.png", + "language": "eng", + "evidence_role": "error_evidence", + }, + }, + }) + + state, err := service.buildScreenAnalysisApprovalState(task) + if err != nil { + t.Fatalf("build screen approval state failed: %v", err) + } + + got := map[string]any{ + "approval_request": state.approvalRequestMap(), + "pending_execution": state.pendingExecutionMap(), + "bubble_message": state.bubbleMessageMap(), + } + got["approval_request"].(map[string]any)["approval_id"] = "" + got["approval_request"].(map[string]any)["created_at"] = "" + got["bubble_message"].(map[string]any)["created_at"] = "" + + want := map[string]any{ + "approval_request": map[string]any{ + "approval_id": "", + "task_id": task.TaskID, + "operation_name": "screen_capture", + "risk_level": "yellow", + "target_object": "inputs/screen.png", + "reason": "screen_capture_requires_authorization", + "status": "pending", + "created_at": "", + }, + "pending_execution": map[string]any{ + "kind": "screen_analysis", + "operation_name": "screen_capture", + "source_path": "inputs/screen.png", + "capture_mode": "screenshot", + "source": "screen_capture", + "target_object": "inputs/screen.png", + "language": "eng", + "evidence_role": "error_evidence", + "delivery_type": "bubble", + "result_title": "屏幕分析结果", + "preview_text": "已准备分析屏幕截图", + "impact_scope": map[string]any{ + "files": []string{"inputs/screen.png"}, + "webpages": []string{}, + "apps": []string{}, + "out_of_workspace": false, + "overwrite_or_delete_risk": false, + }, + }, + "bubble_message": map[string]any{ + "bubble_id": "bubble_" + task.TaskID, + "task_id": task.TaskID, + "type": "status", + "text": "屏幕截图分析属于敏感能力,请先确认授权。", + "pinned": false, + "hidden": false, + "created_at": "", + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected screen approval state:\nwant: %#v\n got: %#v", want, got) + } +} diff --git a/services/local-service/internal/orchestrator/service_test.go b/services/local-service/internal/orchestrator/service_test.go index 0619d9eb1..05fc618bb 100644 --- a/services/local-service/internal/orchestrator/service_test.go +++ b/services/local-service/internal/orchestrator/service_test.go @@ -1209,15 +1209,7 @@ func TestServiceStartTaskAndConfirmFlow(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "这里是一段需要解释的内容", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "这里是一段需要解释的内容"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -1272,15 +1264,7 @@ func TestServiceSubmitInputReturnsSocialChatWithoutTask(t *testing.T) { testCases := []string{"你好", "🙂"} for index, testCase := range testCases { t.Run(testCase, func(t *testing.T) { - result, err := service.SubmitInput(map[string]any{ - "session_id": fmt.Sprintf("sess_short_text_%02d", index), - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": testCase, - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: fmt.Sprintf("sess_short_text_%02d", index), Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: testCase}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1310,15 +1294,7 @@ func TestServiceSubmitInputRoutesUnanchoredAmbiguousTextToConfirmation(t *testin output: `{"route":"clarification_needed","reply":""}`, }) - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_ambiguous_text", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "帮我看下", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_ambiguous_text", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "帮我看下"}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1352,15 +1328,7 @@ func TestServiceSubmitInputKeepsTaskRequestAfterClassifier(t *testing.T) { }, }) - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_classified_task", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Translate this note into English", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_classified_task", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Translate this note into English"}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1393,15 +1361,7 @@ func TestServiceSubmitInputFallsBackToTaskWhenClassifierFails(t *testing.T) { }, }) - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_classifier_fallback", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Summarize the visible note", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_classifier_fallback", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Summarize the visible note"}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1418,18 +1378,7 @@ func TestServiceSubmitInputFallsBackToTaskWhenClassifierFails(t *testing.T) { func TestServiceSubmitInputRespectsExplicitConfirmationForFreeText(t *testing.T) { service := newTestService() - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_confirm_free_text", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "你好", - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_confirm_free_text", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "你好"}, Options: &InputSubmitOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1450,15 +1399,7 @@ func TestServiceSubmitInputRespectsExplicitConfirmationForFreeText(t *testing.T) func TestServiceSubmitInputRoutesClearCommandToAgentLoopWithoutForcedConfirmation(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Translated note ready.") - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_clear_command", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Translate this note into English", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_clear_command", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Translate this note into English"}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1483,15 +1424,7 @@ func TestServiceSubmitInputRoutesClearCommandToAgentLoopWithoutForcedConfirmatio func TestServiceSubmitInputUsesSuggestedWorkspaceDeliveryForLongAgentLoopInput(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "Long-form result body.") - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_long_command", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please review the following document notes and prepare a detailed deliverable:\nLine one explains the rollout plan.\nLine two adds implementation details.\nLine three adds follow-up tasks.", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_long_command", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Please review the following document notes and prepare a detailed deliverable:\nLine one explains the rollout plan.\nLine two adds implementation details.\nLine three adds follow-up tasks."}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1520,19 +1453,10 @@ func TestServiceStartTaskFailsAfterExecutionTimeout(t *testing.T) { }) service.executionTimeout = 20 * time.Millisecond - result, err := service.StartTask(map[string]any{ - "session_id": "sess_execution_timeout", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please rewrite this draft.", - }, - "intent": map[string]any{ - "name": "rewrite", - "arguments": map[string]any{}, - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_execution_timeout", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Please rewrite this draft."}, Intent: map[string]any{ + "name": "rewrite", + "arguments": map[string]any{}, + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -1592,15 +1516,7 @@ func TestServiceSubmitInputWorkspaceDeliverySkipsShortBubbleTimeout(t *testing.T }) service.executionTimeout = 20 * time.Millisecond - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_long_command_timeout", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please review the following document notes and prepare a detailed deliverable:\nLine one explains the rollout plan.\nLine two adds implementation details.\nLine three adds follow-up tasks.", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_long_command_timeout", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Please review the following document notes and prepare a detailed deliverable:\nLine one explains the rollout plan.\nLine two adds implementation details.\nLine three adds follow-up tasks."}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1626,22 +1542,13 @@ func TestServiceSubmitInputWorkspaceDeliverySkipsShortBubbleTimeout(t *testing.T func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued task output.") - firstResult, err := service.StartTask(map[string]any{ - "session_id": "sess_serial", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please write this into a file after authorization.", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "workspace_document", - "require_authorization": true, - }, + firstResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_serial", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Please write this into a file after authorization."}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "workspace_document", + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("first start task failed: %v", err) } @@ -1649,15 +1556,7 @@ func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *tes t.Fatalf("expected first task to wait for authorization, got %+v", firstResult["task"]) } - secondResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_serial", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Translate this note into English", - }, - }) + secondResult, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_serial", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Translate this note into English"}}) if err != nil { t.Fatalf("second submit input failed: %v", err) } @@ -1677,22 +1576,13 @@ func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *tes func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued confirm output.") - firstResult, err := service.StartTask(map[string]any{ - "session_id": "sess_confirm_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please write this into a file after authorization.", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "workspace_document", - "require_authorization": true, - }, + firstResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_confirm_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Please write this into a file after authorization."}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "workspace_document", + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("first start task failed: %v", err) } @@ -1700,18 +1590,7 @@ func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T t.Fatalf("expected first task to wait for authorization, got %+v", firstResult["task"]) } - secondResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_confirm_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "ok", - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + secondResult, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_confirm_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "ok"}, Options: &InputSubmitOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("second submit input failed: %v", err) } @@ -1740,50 +1619,25 @@ func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued cancel output.") - firstResult, err := service.StartTask(map[string]any{ - "session_id": "sess_cancel_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please write this into a file after authorization.", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "workspace_document", - "require_authorization": true, - }, + firstResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_cancel_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Please write this into a file after authorization."}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "workspace_document", + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("first start task failed: %v", err) } firstTaskID := firstResult["task"].(map[string]any)["task_id"].(string) - secondResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_cancel_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Translate this note into English", - }, - }) + secondResult, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_cancel_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Translate this note into English"}}) if err != nil { t.Fatalf("second submit input failed: %v", err) } secondTaskID := secondResult["task"].(map[string]any)["task_id"].(string) - thirdResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_cancel_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Summarize this release note for me", - }, - }) + thirdResult, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_cancel_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Summarize this release note for me"}}) if err != nil { t.Fatalf("third submit input failed: %v", err) } @@ -1813,51 +1667,22 @@ func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *test func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Snapshot resume output.") - firstResult, err := service.StartTask(map[string]any{ - "session_id": "sess_snapshot_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please write this into a file after authorization.", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "workspace_document", - "require_authorization": true, - }, + firstResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_snapshot_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Please write this into a file after authorization."}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "workspace_document", + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("first start task failed: %v", err) } firstTaskID := firstResult["task"].(map[string]any)["task_id"].(string) - secondResult, err := service.StartTask(map[string]any{ - "session_id": "sess_snapshot_queue", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "Selected source text", - }, - "context": map[string]any{ - "selection": map[string]any{ - "text": "Selected source text", - }, - "files": []any{"workspace/docs/input.md"}, - "page": map[string]any{ - "title": "Release Notes", - "url": "https://example.com/release", - "app_name": "browser", - }, - }, - "intent": map[string]any{ - "name": "agent_loop", - "arguments": map[string]any{}, - }, - }) + secondResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_snapshot_queue", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "Selected source text"}, Context: &InputContext{Selection: &SelectionContext{Text: "Selected source text"}, Files: []string{"workspace/docs/input.md"}, Page: &PageContext{Title: "Release Notes", URL: "https://example.com/release", AppName: "browser"}}, Intent: map[string]any{ + "name": "agent_loop", + "arguments": map[string]any{}, + }}) if err != nil { t.Fatalf("second start task failed: %v", err) } @@ -1893,36 +1718,19 @@ func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing. func TestServiceSecurityRespondResumesQueuedSessionTask(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued task resumed output.") - firstResult, err := service.StartTask(map[string]any{ - "session_id": "sess_resume_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please write this into a file after authorization.", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "workspace_document", - "require_authorization": true, - }, + firstResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_resume_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Please write this into a file after authorization."}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "workspace_document", + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("first start task failed: %v", err) } firstTaskID := firstResult["task"].(map[string]any)["task_id"].(string) - secondResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_resume_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Translate this note into English", - }, - }) + secondResult, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_resume_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Translate this note into English"}}) if err != nil { t.Fatalf("second submit input failed: %v", err) } @@ -1959,42 +1767,24 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * t.Fatalf("write screen input failed: %v", err) } - firstResult, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please write this into a file after authorization.", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "workspace_document", - "require_authorization": true, - }, + firstResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Please write this into a file after authorization."}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "workspace_document", + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("first start task failed: %v", err) } firstTaskID := firstResult["task"].(map[string]any)["task_id"].(string) - secondResult, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_queue", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请分析屏幕中的错误", - }, - "intent": map[string]any{ - "name": "screen_analyze", - "arguments": map[string]any{ - "path": "inputs/screen.png", - }, + secondResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_queue", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请分析屏幕中的错误"}, Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "inputs/screen.png", }, - }) + }}) if err != nil { t.Fatalf("second start task failed: %v", err) } @@ -2035,7 +1825,7 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * if screenResult["task"].(map[string]any)["status"] != "completed" { t.Fatalf("expected queued screen task to complete after approval, got %+v", screenResult["task"]) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": secondTaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: secondTaskID}) if err != nil { t.Fatalf("task detail get for queued screen task failed: %v", err) } @@ -2066,18 +1856,7 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * func TestServiceConfirmTaskRunsStoredAgentLoopIntentWithoutCorrection(t *testing.T) { service, _ := newTestServiceWithModelClient(t, &stubToolCallingModelClient{}) - startResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_unknown_confirm", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "你好", - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + startResult, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_unknown_confirm", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "你好"}, Options: &InputSubmitOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -2183,18 +1962,7 @@ func TestExecuteTaskResultPageWaitingInputDoesNotClaimDeliveryInTrace(t *testing func TestServiceConfirmTaskKeepsUnknownIntentInConfirmationWhenRejected(t *testing.T) { service := newTestService() - startResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_unknown_cancel", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "你好", - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + startResult, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_unknown_cancel", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "你好"}, Options: &InputSubmitOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -2230,18 +1998,7 @@ func TestServiceConfirmTaskKeepsUnknownIntentInConfirmationWhenRejected(t *testi func TestServiceConfirmTaskRewritesPlaceholderTitleAfterCorrection(t *testing.T) { service := newTestService() - startResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_unknown_title", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "你好", - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + startResult, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_unknown_title", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "你好"}, Options: &InputSubmitOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -2297,16 +2054,16 @@ func TestServiceConfirmTaskConsumesCorrectionTextOnSameTask(t *testing.T) { }, }) - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_confirm_correction_text", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "selected paragraph", - "page_context": map[string]any{ - "title": "Issue 474", - "url": "https://example.test/issues/474", + startResult, err := startTaskForTest(service, StartTaskRequest{ + SessionID: "sess_confirm_correction_text", + Source: "floating_ball", + Trigger: "text_selected_click", + Input: TaskStartInput{ + Type: "text_selection", + Text: "selected paragraph", + PageContext: &PageContext{ + Title: "Issue 474", + URL: "https://example.test/issues/474", }, }, }) @@ -2360,15 +2117,7 @@ func TestServiceConfirmTaskConsumesCorrectionTextOnSameTask(t *testing.T) { func TestServiceConfirmTaskRejectsCorrectionPayloadConflicts(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Explained content.") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_confirm_ignore_correction", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "这里是一段需要解释的内容", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_confirm_ignore_correction", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "这里是一段需要解释的内容"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -2405,16 +2154,16 @@ func TestServiceConfirmTaskRejectsCorrectionPayloadConflicts(t *testing.T) { func TestServiceConfirmTaskExecutesCorrectedResultPageIntent(t *testing.T) { service := newTestService() - startResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_invalid_corrected_intent", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "你好", + startResult, err := submitInputForTest(service, SubmitInputRequest{ + SessionID: "sess_invalid_corrected_intent", + Source: "floating_ball", + Trigger: "hover_text_input", + Input: InputSubmitInput{ + Type: "text", + Text: "你好", }, - "options": map[string]any{ - "confirm_required": true, + Options: &InputSubmitOptions{ + ConfirmRequired: true, }, }) if err != nil { @@ -2466,24 +2215,24 @@ func TestServiceConfirmTaskDowngradesUnavailableCorrectionTextIntent(t *testing. }) service.attachExecutor(nil) - startResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_screen_correction_fallback", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "帮我总结这个构建问题", - }, - "context": map[string]any{ - "page": map[string]any{ - "title": "Build Dashboard", - "window_title": "Browser - Build Dashboard", - "visible_text": "Fatal build error: missing release asset", + startResult, err := submitInputForTest(service, SubmitInputRequest{ + SessionID: "sess_screen_correction_fallback", + Source: "floating_ball", + Trigger: "hover_text_input", + Input: InputSubmitInput{ + Type: "text", + Text: "帮我总结这个构建问题", + }, + Context: &InputContext{ + Page: &PageContext{ + Title: "Build Dashboard", + WindowTitle: "Browser - Build Dashboard", + VisibleText: "Fatal build error: missing release asset", }, - "screen_summary": "release validation failed on current screen", + ScreenSummary: "release validation failed on current screen", }, - "options": map[string]any{ - "confirm_required": true, + Options: &InputSubmitOptions{ + ConfirmRequired: true, }, }) if err != nil { @@ -2543,22 +2292,22 @@ func TestServiceConfirmTaskKeepsNaturalLanguageBrowserCorrectionOnSameTask(t *te }, }) - startResult, err := service.SubmitInput(map[string]any{ - "session_id": "sess_browser_correction", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "帮我总结这个 PR", + startResult, err := submitInputForTest(service, SubmitInputRequest{ + SessionID: "sess_browser_correction", + Source: "floating_ball", + Trigger: "hover_text_input", + Input: InputSubmitInput{ + Type: "text", + Text: "帮我总结这个 PR", }, - "context": map[string]any{ - "page": map[string]any{ - "title": "PR 512", - "url": "https://example.test/pr/512", + Context: &InputContext{ + Page: &PageContext{ + Title: "PR 512", + URL: "https://example.test/pr/512", }, }, - "options": map[string]any{ - "confirm_required": true, + Options: &InputSubmitOptions{ + ConfirmRequired: true, }, }) if err != nil { @@ -2598,15 +2347,7 @@ func TestServiceConfirmTaskKeepsNaturalLanguageBrowserCorrectionOnSameTask(t *te func TestServiceConfirmTaskRejectsOutOfPhaseRequest(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_confirm_out_of_phase", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "请生成一个文件版本", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_confirm_out_of_phase", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "请生成一个文件版本"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -3929,15 +3670,7 @@ func TestServiceNotepadUpdateReturnsDeletedItemID(t *testing.T) { func TestServiceExecutionAuditIDsStayUniqueAcrossToolAndTaskRecords(t *testing.T) { service, _ := newTestServiceWithExecution(t, "runtime output") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_audit", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "解释一下这段内容", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_audit", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "解释一下这段内容"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -3971,15 +3704,7 @@ func TestServiceExecutionAuditIDsStayUniqueAcrossToolAndTaskRecords(t *testing.T func TestServiceRecommendationGetUsesRuntimeTaskState(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_recommend", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "tiny note", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_recommend", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "tiny note"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4533,19 +4258,10 @@ func TestServiceTaskControlRestartExecutesFreshAttempt(t *testing.T) { modelClient := &stubToolCallingModelClient{output: "Restarted loop output."} service, _ := newTestServiceWithModelClient(t, modelClient) - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_restart_attempt", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Translate this note into English.", - }, - "intent": map[string]any{ - "name": "agent_loop", - "arguments": map[string]any{}, - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_restart_attempt", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Translate this note into English."}, Intent: map[string]any{ + "name": "agent_loop", + "arguments": map[string]any{}, + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4910,7 +4626,7 @@ func TestServiceTaskDetailGetRestartAttemptHidesPreviousRunFormalObjects(t *test t.Fatalf("expected restart to allocate a fresh processing attempt, got %+v", restartedTask) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": task.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: task.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -4986,7 +4702,7 @@ func TestServiceRestartPreparationStaysInvisibleUntilAttemptCommits(t *testing.T t.Fatalf("expected live task to stay on the previous finished attempt before commit, got %+v", liveTask) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": task.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: task.TaskID}) if err != nil { t.Fatalf("task detail get during restart preparation failed: %v", err) } @@ -5190,19 +4906,7 @@ func TestServiceRecommendationFeedbackSubmitAppliesCooldown(t *testing.T) { func TestServiceSubmitInputWithFilesDoesNotWaitForInput(t *testing.T) { service := newTestService() - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_files", - "source": "floating_ball", - "input": map[string]any{ - "files": []any{"workspace/notes.md"}, - }, - "context": map[string]any{ - "page": map[string]any{ - "title": "Workspace", - "app_name": "desktop", - }, - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_files", Source: "floating_ball", Input: InputSubmitInput{}, Context: &InputContext{Files: []string{"workspace/notes.md"}, Page: &PageContext{Title: "Workspace", AppName: "desktop"}}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -5234,17 +4938,7 @@ func TestServiceSubmitInputEmptyTextReturnsWaitingInput(t *testing.T) { t.Fatalf("new service: %v", err) } - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": " ", - "input_mode": "text", - }, - "context": map[string]any{}, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: " ", InputMode: "text"}, Context: &InputContext{}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -5299,21 +4993,12 @@ func TestServiceDirectStartBuildsMemoryAndDeliveryHandoffs(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "直接总结这段文字", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "直接总结这段文字"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5354,25 +5039,12 @@ func TestServiceDirectStartBuildsMemoryAndDeliveryHandoffs(t *testing.T) { func TestServiceStartTaskRespectsPreferredDelivery(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "direct summarize with bubble delivery", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, - }, - "delivery": map[string]any{ - "preferred": "bubble", - "fallback": "workspace_document", + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "direct summarize with bubble delivery"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }, Delivery: &DeliveryPreference{Preferred: "bubble", Fallback: "workspace_document"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5404,20 +5076,7 @@ func TestServiceStartTaskRespectsPreferredDelivery(t *testing.T) { func TestServiceStartTaskFileInstructionSkipsForcedIntentConfirmation(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Attachment summary ready.") - result, err := service.StartTask(map[string]any{ - "session_id": "sess_file_instruction", - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "text": "帮我看看这里面有什么", - "files": []any{"workspace/MyToDos_Vue"}, - }, - "delivery": map[string]any{ - "preferred": "bubble", - "fallback": "task_detail", - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_file_instruction", Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Text: "帮我看看这里面有什么", Files: []string{"workspace/MyToDos_Vue"}}, Delivery: &DeliveryPreference{Preferred: "bubble", Fallback: "task_detail"}}) if err != nil { t.Fatalf("start file task failed: %v", err) } @@ -5440,18 +5099,7 @@ func TestServiceStartTaskFileInstructionSkipsForcedIntentConfirmation(t *testing func TestServiceStartTaskFileWithoutInstructionStillRequiresConfirmation(t *testing.T) { service := newTestService() - result, err := service.StartTask(map[string]any{ - "session_id": "sess_file_needs_goal", - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "files": []any{"workspace/MyToDos_Vue"}, - }, - "options": map[string]any{ - "confirm_required": false, - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_file_needs_goal", Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Files: []string{"workspace/MyToDos_Vue"}}, Options: &TaskStartOptions{ConfirmRequired: false}}) if err != nil { t.Fatalf("start file task failed: %v", err) } @@ -5482,21 +5130,12 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { t.Fatalf("write source file: %v", err) } - result, err := service.StartTask(map[string]any{ - "session_id": "sess_read_file_sample", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请读取这个文件", - }, - "intent": map[string]any{ - "name": "read_file", - "arguments": map[string]any{ - "path": "notes/source.txt", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_read_file_sample", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请读取这个文件"}, Intent: map[string]any{ + "name": "read_file", + "arguments": map[string]any{ + "path": "notes/source.txt", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5552,7 +5191,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { t.Fatalf("expected persisted direct delivery result, ok=%v record=%+v", ok, deliveryRecord) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -5569,19 +5208,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { func TestServiceSubmitInputRespectsPreferredDelivery(t *testing.T) { service := newTestService() - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "translate this line", - }, - "options": map[string]any{ - "confirm_required": false, - "preferred_delivery": "workspace_document", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "translate this line"}, Options: &InputSubmitOptions{ConfirmRequired: false, PreferredDelivery: "workspace_document"}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -5615,18 +5242,7 @@ func TestServiceSubmitInputRespectsPreferredDelivery(t *testing.T) { func TestServiceConfirmTaskRespectsStoredPreferredDelivery(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "selected text for confirmation flow", - }, - "delivery": map[string]any{ - "preferred": "workspace_document", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "selected text for confirmation flow"}, Delivery: &DeliveryPreference{Preferred: "workspace_document"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5676,22 +5292,13 @@ func TestServiceStartTaskWaitingAuthDoesNotSetFinishedAt(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "files": []any{"workspace/input.md"}, - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - "target_path": "workspace_document", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Files: []string{"workspace/input.md"}}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, + "target_path": "workspace_document", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5728,15 +5335,15 @@ func TestServiceAgentLoopToolApprovalPausesWaitingAuth(t *testing.T) { } service, _ := newTestServiceWithModelClient(t, modelClient) - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_agent_loop_auth", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "去 http://127.0.0.1:8080/approval 看一下页面内容", + startResult, err := startTaskForTest(service, StartTaskRequest{ + SessionID: "sess_agent_loop_auth", + Source: "floating_ball", + Trigger: "hover_text_input", + Input: TaskStartInput{ + Type: "text", + Text: "去 https://example.com 看一下页面内容", }, - "intent": map[string]any{ + Intent: map[string]any{ "name": "agent_loop", "arguments": map[string]any{}, }, @@ -5818,15 +5425,15 @@ func TestServiceRuntimeApprovalResumeRejectsChangedToolInput(t *testing.T) { } service, _ := newTestServiceWithModelClient(t, modelClient) - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_agent_loop_exec_auth", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "先看看当前仓库状态", + startResult, err := startTaskForTest(service, StartTaskRequest{ + SessionID: "sess_agent_loop_exec_auth", + Source: "floating_ball", + Trigger: "hover_text_input", + Input: TaskStartInput{ + Type: "text", + Text: "先看看当前仓库状态", }, - "intent": map[string]any{ + Intent: map[string]any{ "name": "agent_loop", "arguments": map[string]any{}, }, @@ -5925,15 +5532,7 @@ func TestServiceConfirmCanEnterWaitingAuth(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "这里是一段需要确认处理方式的内容", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "这里是一段需要确认处理方式的内容"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5992,15 +5591,7 @@ func TestServiceConfirmWaitingAuthPersistsApprovalRequestRecord(t *testing.T) { t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_approval_store", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "persist approval request before execution", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_approval_store", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "persist approval request before execution"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6045,15 +5636,7 @@ func TestServiceConfirmTaskReturnsStorageErrorWhenApprovalPersistenceFails(t *te defer replaceApprovalRequestStore(t, service.storage, originalStore) replaceApprovalRequestStore(t, service.storage, failingApprovalRequestStore{base: originalStore, err: errors.New("approval store unavailable")}) - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_approval_store_failure", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "persist approval request before execution", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_approval_store_failure", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "persist approval request before execution"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6093,15 +5676,7 @@ func TestServiceSecurityRespondAllowOnceResumesAndCompletes(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "需要授权后继续执行的内容", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "需要授权后继续执行的内容"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6186,19 +5761,7 @@ func TestServiceSecurityRespondAllowOnceResumesAndCompletes(t *testing.T) { func TestServiceSecurityRespondRespectsFallbackDelivery(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "authorization flow with delivery fallback", - }, - "delivery": map[string]any{ - "preferred": "unsupported_delivery", - "fallback": "bubble", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "authorization flow with delivery fallback"}, Delivery: &DeliveryPreference{Preferred: "unsupported_delivery", Fallback: "bubble"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6257,15 +5820,7 @@ func TestServiceSecurityRespondDenyOnceCancelsTask(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "需要授权后继续执行的内容", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "需要授权后继续执行的内容"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6322,15 +5877,7 @@ func TestServiceSecurityRespondPersistsAuthorizationRecord(t *testing.T) { t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_authorization_store", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "persist authorization decision after approval", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_authorization_store", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "persist authorization decision after approval"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6392,15 +5939,7 @@ func TestServiceSecurityRespondReturnsStorageErrorWhenAuthorizationPersistenceFa originalStore := service.storage.AuthorizationRecordStore() defer replaceAuthorizationRecordStore(t, service.storage, originalStore) - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_authorization_store_failure", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "persist authorization decision after approval", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_authorization_store_failure", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "persist authorization decision after approval"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6446,21 +5985,12 @@ func TestServiceSecurityRespondRejectsOutOfPhaseAuthorizationPersistence(t *test t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_authorization_out_of_phase", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请覆盖该文件", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "notes/output.md", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_authorization_out_of_phase", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请覆盖该文件"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "notes/output.md", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6504,23 +6034,27 @@ func TestServiceSecurityRespondRejectsStaleApprovalIDAfterRebuiltCycle(t *testin }, }) - staleApproval, stalePendingExecution, _, err := service.buildScreenAnalysisApprovalState(task) + staleApprovalState, err := service.buildScreenAnalysisApprovalState(task) if err != nil { t.Fatalf("build stale screen approval state failed: %v", err) } - if err := service.persistApprovalRequestState(task.TaskID, staleApproval, mapValue(stalePendingExecution, "impact_scope")); err != nil { + staleApproval := staleApprovalState.approvalRequestMap() + if err := service.persistApprovalRequestState(task.TaskID, staleApproval, staleApprovalState.PendingExecution.ImpactScope.mapValue()); err != nil { t.Fatalf("persist stale approval request failed: %v", err) } - activeApproval, activePendingExecution, activeBubble, err := service.buildScreenAnalysisApprovalState(task) + activeApprovalState, err := service.buildScreenAnalysisApprovalState(task) if err != nil { t.Fatalf("build active screen approval state failed: %v", err) } + activeApproval := activeApprovalState.approvalRequestMap() + activePendingExecution := activeApprovalState.pendingExecutionMap() + activeBubble := activeApprovalState.bubbleMessageMap() updatedTask, ok := service.runEngine.MarkWaitingApprovalWithPlan(task.TaskID, activeApproval, activePendingExecution, activeBubble) if !ok { t.Fatalf("mark waiting approval with rebuilt cycle failed for task %s", task.TaskID) } - if err := service.persistApprovalRequestState(updatedTask.TaskID, activeApproval, mapValue(activePendingExecution, "impact_scope")); err != nil { + if err := service.persistApprovalRequestState(updatedTask.TaskID, activeApproval, activeApprovalState.PendingExecution.ImpactScope.mapValue()); err != nil { t.Fatalf("persist active approval request failed: %v", err) } @@ -6553,21 +6087,12 @@ func TestServiceSecurityRespondRejectsUnsupportedDecision(t *testing.T) { t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_unsupported_approval_decision", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请覆盖该文件", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "notes/output.md", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_unsupported_approval_decision", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请覆盖该文件"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "notes/output.md", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6610,21 +6135,12 @@ func TestServiceSecurityRespondKeepsAuthorizationHistoryAcrossMultipleCycles(t * t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_authorization_history", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请覆盖该文件", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "notes/output.md", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_authorization_history", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请覆盖该文件"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "notes/output.md", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6681,21 +6197,12 @@ func TestServiceStartTaskWriteFileOverwriteWaitsForApproval(t *testing.T) { t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_overwrite", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请覆盖该文件", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "notes/output.md", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_overwrite", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请覆盖该文件"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "notes/output.md", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6719,22 +6226,13 @@ func TestServiceStartTaskWriteFileOverwriteWaitsForApproval(t *testing.T) { func TestServiceStartTaskExecCommandWaitsForApproval(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "unused") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_exec_cmd", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "执行命令", - }, - "intent": map[string]any{ - "name": "exec_command", - "arguments": map[string]any{ - "command": "cmd", - "args": []any{"/c", "echo", "ok"}, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec_cmd", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "执行命令"}, Intent: map[string]any{ + "name": "exec_command", + "arguments": map[string]any{ + "command": "cmd", + "args": []any{"/c", "echo", "ok"}, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6758,21 +6256,12 @@ func TestServiceStartTaskExecCommandWaitsForApproval(t *testing.T) { func TestServiceStartTaskOutOfWorkspaceWriteIsBlocked(t *testing.T) { service, _ := newTestServiceWithExecution(t, "unused") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_outside", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "越界写入", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "../secret.txt", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_outside", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "越界写入"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "../secret.txt", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6795,22 +6284,13 @@ func TestServiceStartTaskOutOfWorkspaceWriteIsBlocked(t *testing.T) { func TestServiceSecurityRespondAllowOnceReturnsStructuredExecutionFailure(t *testing.T) { service, _ := newTestServiceWithExecutionOptions(t, "unused", failingExecutionBackend{err: errors.New("runner unavailable")}, nil) - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_exec_fail", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "执行命令", - }, - "intent": map[string]any{ - "name": "exec_command", - "arguments": map[string]any{ - "command": "cmd", - "args": []any{"/c", "echo", "ok"}, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec_fail", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "执行命令"}, Intent: map[string]any{ + "name": "exec_command", + "arguments": map[string]any{ + "command": "cmd", + "args": []any{"/c", "echo", "ok"}, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6857,22 +6337,13 @@ func TestServiceSecurityRespondAllowOnceExecCommandCompletesAfterApproval(t *tes t.Fatalf("mkdir workspace root: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_exec_allow", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "执行命令", - }, - "intent": map[string]any{ - "name": "exec_command", - "arguments": map[string]any{ - "command": "cmd", - "args": []any{"/c", "echo", "ok"}, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec_allow", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "执行命令"}, Intent: map[string]any{ + "name": "exec_command", + "arguments": map[string]any{ + "command": "cmd", + "args": []any{"/c", "echo", "ok"}, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6901,22 +6372,13 @@ func TestServiceSecurityRespondAllowOnceExecCommandCompletesAfterApproval(t *tes func TestServiceSecurityRespondAllowOnceCompletesDerivedWriteFileAfterApproval(t *testing.T) { service, _ := newTestServiceWithExecution(t, "新的文档内容") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_derived_write", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请总结成文档", + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_derived_write", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请总结成文档"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", + "require_authorization": true, }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - "require_authorization": true, - }, - }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6948,21 +6410,12 @@ func TestServiceSecurityRespondAllowOnceReturnsStructuredRecoveryFailure(t *test t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_recovery_fail", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请覆盖该文件", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "notes/output.md", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_recovery_fail", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请覆盖该文件"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "notes/output.md", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -7010,40 +6463,22 @@ func TestServiceTaskListSupportsSortParams(t *testing.T) { return current }) - firstResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "first finished task", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + firstResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "first finished task"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start first task failed: %v", err) } - secondResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "second finished task", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + secondResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "second finished task"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start second task failed: %v", err) } @@ -7446,21 +6881,12 @@ func TestServiceTaskListUsesStructuredStoreUnlimitedPaginationInMemoryFallback(t func TestServiceTaskListMergesStructuredStorageWhenOffsetExceedsRuntimePage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "runtime paging") - _, err := service.StartTask(map[string]any{ - "session_id": "sess_page", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "runtime finished task", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + _, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_page", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "runtime finished task"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start runtime task failed: %v", err) } @@ -7509,21 +6935,12 @@ func TestServiceTaskListClampsPagingParams(t *testing.T) { service := newTestService() for index := 0; index < 25; index++ { - _, err := service.StartTask(map[string]any{ - "session_id": fmt.Sprintf("sess_clamp_%02d", index), - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": fmt.Sprintf("task %02d for task list clamp", index), - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + _, err := startTaskForTest(service, StartTaskRequest{SessionID: fmt.Sprintf("sess_clamp_%02d", index), Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: fmt.Sprintf("task %02d for task list clamp", index)}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task %d failed: %v", index, err) } @@ -7686,40 +7103,22 @@ func TestServiceTaskListFallbackMatchesRuntimeSortTieBreaker(t *testing.T) { func TestServiceDashboardOverviewUsesRuntimeAggregation(t *testing.T) { service := newTestService() - completedResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "completed task for dashboard overview", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + completedResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "completed task for dashboard overview"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start completed task failed: %v", err) } - waitingResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "waiting authorization task for dashboard overview", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + waitingResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "waiting authorization task for dashboard overview"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start waiting auth task failed: %v", err) } @@ -7869,27 +7268,18 @@ func TestIsWorkspaceRelativePathKeepsArtifactsInsideWorkspaceScope(t *testing.T) func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_detail", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "task detail should expose waiting approval anchor", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "task detail should expose waiting approval anchor"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } taskID := startResult["task"].(map[string]any)["task_id"].(string) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -7917,21 +7307,12 @@ func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_detail_stale", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "stale approval anchors must not leak into task detail", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail_stale", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "stale approval anchors must not leak into task detail"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -7949,7 +7330,7 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi runtimeRecord.SecuritySummary["pending_authorizations"] = 1 }) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -7963,24 +7344,138 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi } } -func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing.T) { +func TestServiceTaskDetailGetDerivesInterceptedStatusFromAuthorizationDecision(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_detail_bad_task", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "mismatched approval anchors must not leak into task detail", + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail_intercepted", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "intercepted task detail inference"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, + }}) + if err != nil { + t.Fatalf("start task failed: %v", err) + } + + taskID := startResult["task"].(map[string]any)["task_id"].(string) + if _, err := service.SecurityRespond(map[string]any{ + "task_id": taskID, + "approval_id": activeApprovalIDForTask(t, service, taskID), + "decision": "deny_once", + "remember_rule": false, + }); err != nil { + t.Fatalf("security respond failed: %v", err) + } + + mutateRuntimeTask(t, service.runEngine, taskID, func(runtimeRecord *runengine.TaskRecord) { + delete(runtimeRecord.SecuritySummary, "security_status") + }) + + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) + if err != nil { + t.Fatalf("task detail get failed: %v", err) + } + if detailResult["security_summary"].(map[string]any)["security_status"] != "intercepted" { + t.Fatalf("expected intercepted status from denied authorization, got %+v", detailResult["security_summary"]) + } +} + +func TestServiceTaskDetailGetDerivesInterceptedStatusFromDeniedAuditRecord(t *testing.T) { + service := newTestService() + + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail_intercepted_audit", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "blocked by governance before approval prompt"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, + }, + }}) + if err != nil { + t.Fatalf("start task failed: %v", err) + } + + taskID := startResult["task"].(map[string]any)["task_id"].(string) + task, ok := service.runEngine.GetTask(taskID) + if !ok { + t.Fatal("expected runtime task to exist") + } + mutateRuntimeTask(t, service.runEngine, taskID, func(runtimeRecord *runengine.TaskRecord) { + delete(runtimeRecord.SecuritySummary, "security_status") + runtimeRecord.Status = "blocked" + runtimeRecord.ApprovalRequest = nil + runtimeRecord.Authorization = nil + runtimeRecord.AuditRecords = []map[string]any{{ + "audit_id": "audit_detail_intercepted", + "task_id": taskID, + "run_id": task.RunID, + "type": "risk", + "action": "intercept_operation", + "summary": "high risk operation was intercepted before approval", + "target": "workspace/blocked.md", + "result": "denied", + "created_at": task.UpdatedAt.Add(time.Second).Format(time.RFC3339Nano), + }} + }) + + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) + if err != nil { + t.Fatalf("task detail get failed: %v", err) + } + if detailResult["security_summary"].(map[string]any)["security_status"] != "intercepted" { + t.Fatalf("expected intercepted status from denied audit record, got %+v", detailResult["security_summary"]) + } +} + +func TestServiceTaskDetailGetDerivesRecoverableStatusFromRestorePoint(t *testing.T) { + service, _ := newTestServiceWithExecution(t, "recoverable detail inference") + if service.storage == nil { + t.Fatal("expected storage service to be wired") + } + + taskID := "task_detail_recoverable" + err := service.storage.TaskRunStore().SaveTaskRun(context.Background(), storage.TaskRunRecord{ + TaskID: taskID, + SessionID: "sess_detail_recoverable", + RunID: "run_detail_recoverable", + Title: "recoverable detail inference", + SourceType: "hover_input", + Status: "completed", + CurrentStep: "deliver_result", + RiskLevel: "yellow", + StartedAt: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 16, 10, 5, 0, 0, time.UTC), + FinishedAt: timePointer(time.Date(2026, 4, 16, 10, 6, 0, 0, time.UTC)), + SecuritySummary: map[string]any{ + "latest_restore_point": map[string]any{ + "recovery_point_id": "rp_detail_recoverable", + "task_id": taskID, + "summary": "recoverable detail inference", + "created_at": "2026-04-16T10:06:00Z", + "objects": []string{"workspace/recoverable.md"}, }, }, }) + if err != nil { + t.Fatalf("save task run failed: %v", err) + } + + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) + if err != nil { + t.Fatalf("task detail get failed: %v", err) + } + if detailResult["security_summary"].(map[string]any)["security_status"] != "recoverable" { + t.Fatalf("expected recoverable status from latest restore point, got %+v", detailResult["security_summary"]) + } +} + +func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing.T) { + service := newTestService() + + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail_bad_task", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "mismatched approval anchors must not leak into task detail"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, + }, + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -7990,7 +7485,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing. runtimeRecord.ApprovalRequest["task_id"] = "task_other" }) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -8007,21 +7502,12 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing. func TestServiceTaskDetailGetDropsApprovalAnchorWhenStatusIsNotPending(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_detail_bad_status", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "non-pending approval anchors must not leak into task detail", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail_bad_status", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "non-pending approval anchors must not leak into task detail"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -8031,7 +7517,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWhenStatusIsNotPending(t *testin runtimeRecord.ApprovalRequest["status"] = "approved" }) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -8045,21 +7531,12 @@ func TestServiceTaskDetailGetIncludesRuntimeSummary(t *testing.T) { if service.storage == nil || service.storage.LoopRuntimeStore() == nil { t.Fatal("expected loop runtime store to be wired") } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_detail_runtime_summary", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "task detail should expose runtime summary", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail_runtime_summary", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "task detail should expose runtime summary"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -8096,7 +7573,7 @@ func TestServiceTaskDetailGetIncludesRuntimeSummary(t *testing.T) { t.Fatal("expected steering append to succeed") } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -8126,15 +7603,7 @@ func TestServiceTaskDetailGetIncludesRuntimeSummary(t *testing.T) { func TestServiceTaskDetailGetKeepsRuntimeSummaryScopedToRuntimeEvents(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail runtime event scope") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_detail_runtime_scope", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "task detail runtime summary should ignore non-runtime latest events", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail_runtime_scope", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "task detail runtime summary should ignore non-runtime latest events"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -8143,7 +7612,7 @@ func TestServiceTaskDetailGetKeepsRuntimeSummaryScopedToRuntimeEvents(t *testing t.Fatal("expected steering append to succeed") } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -8201,7 +7670,7 @@ func TestServiceTaskDetailGetIncludesFailureSummaryForFailedScreenTask(t *testin }, }, tools.ErrOCRWorkerFailed) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": updatedTask.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: updatedTask.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -8496,40 +7965,22 @@ func TestServiceDashboardOverviewRespectsIncludeFilter(t *testing.T) { func TestServiceDashboardOverviewFocusModeNarrowsSecondaryData(t *testing.T) { service := newTestService() - _, err := service.StartTask(map[string]any{ - "session_id": "sess_focus", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "completed task for focus mode", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + _, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_focus", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "completed task for focus mode"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start completed task failed: %v", err) } - _, err = service.StartTask(map[string]any{ - "session_id": "sess_focus", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "waiting authorization task for focus mode", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + _, err = startTaskForTest(service, StartTaskRequest{SessionID: "sess_focus", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "waiting authorization task for focus mode"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start waiting auth task failed: %v", err) } @@ -8650,21 +8101,12 @@ func TestServiceDashboardOverviewResortsMergedRuntimeAndStoredTasks(t *testing.T t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ - "session_id": "sess_merge_overview", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "runtime task should not win when stored task is newer", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + runtimeResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_merge_overview", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "runtime task should not win when stored task is newer"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start runtime task failed: %v", err) } @@ -8733,21 +8175,12 @@ func TestServiceDashboardOverviewLoadsStoredTasksOncePerRequest(t *testing.T) { countingStore := &countingTaskStore{base: service.storage.TaskStore()} replaceTaskStore(t, service.storage, countingStore) - if _, err := service.StartTask(map[string]any{ - "session_id": "sess_overview_count", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "runtime task for overview count", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + if _, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_overview_count", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "runtime task for overview count"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }); err != nil { + }}); err != nil { t.Fatalf("start runtime task failed: %v", err) } @@ -8785,21 +8218,12 @@ func TestServiceDashboardOverviewLoadsStoredTasksOncePerRequest(t *testing.T) { func TestServiceMirrorOverviewUsesRuntimeMirrorReferences(t *testing.T) { service := newTestService() - _, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "mirror overview should reuse runtime memory plans", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + _, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "mirror overview should reuse runtime memory plans"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -8834,21 +8258,12 @@ func TestServiceStartTaskWritesRealMemorySummary(t *testing.T) { t.Fatal("expected storage service to be wired") } - result, err := service.StartTask(map[string]any{ - "session_id": "sess_memory_write", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请总结 project alpha 的进展", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_memory_write", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请总结 project alpha 的进展"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -8878,21 +8293,12 @@ func TestServiceStartTaskHandlesControlledScreenAnalyzeIntent(t *testing.T) { if err := os.WriteFile(filepath.Join(workspaceRoot, "inputs", "screen.png"), []byte("fake screen capture"), 0o644); err != nil { t.Fatalf("write screen input failed: %v", err) } - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_task", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请分析屏幕中的错误", - }, - "intent": map[string]any{ - "name": "screen_analyze", - "arguments": map[string]any{ - "path": "inputs/screen.png", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_task", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请分析屏幕中的错误"}, Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "inputs/screen.png", }, - }) + }}) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -8954,7 +8360,7 @@ func TestServiceStartTaskHandlesControlledScreenAnalyzeIntent(t *testing.T) { if payload["screen_session_id"] == "" || payload["capture_mode"] != "screenshot" || payload["retention_policy"] == "" || payload["evidence_role"] != "error_evidence" { t.Fatalf("expected persisted artifact payload to retain screen metadata, got %+v", payload) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": task["task_id"]}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: task["task_id"].(string)}) if err != nil { t.Fatalf("task detail get for screen task failed: %v", err) } @@ -9010,22 +8416,24 @@ func TestBuildScreenAnalysisApprovalStateKeepsApprovalHistory(t *testing.T) { }, }) - firstApproval, firstPendingExecution, _, err := service.buildScreenAnalysisApprovalState(task) + firstApprovalState, err := service.buildScreenAnalysisApprovalState(task) if err != nil { t.Fatalf("build first screen approval state failed: %v", err) } - secondApproval, secondPendingExecution, _, err := service.buildScreenAnalysisApprovalState(task) + secondApprovalState, err := service.buildScreenAnalysisApprovalState(task) if err != nil { t.Fatalf("build second screen approval state failed: %v", err) } + firstApproval := firstApprovalState.approvalRequestMap() + secondApproval := secondApprovalState.approvalRequestMap() if firstApproval["approval_id"] == secondApproval["approval_id"] { t.Fatalf("expected fresh approval ids across screen authorization cycles, got %+v and %+v", firstApproval, secondApproval) } - if err := service.persistApprovalRequestState(task.TaskID, firstApproval, mapValue(firstPendingExecution, "impact_scope")); err != nil { + if err := service.persistApprovalRequestState(task.TaskID, firstApproval, firstApprovalState.PendingExecution.ImpactScope.mapValue()); err != nil { t.Fatalf("persist first approval request failed: %v", err) } - if err := service.persistApprovalRequestState(task.TaskID, secondApproval, mapValue(secondPendingExecution, "impact_scope")); err != nil { + if err := service.persistApprovalRequestState(task.TaskID, secondApproval, secondApprovalState.PendingExecution.ImpactScope.mapValue()); err != nil { t.Fatalf("persist second approval request failed: %v", err) } @@ -9072,20 +8480,18 @@ func TestBuildScreenAnalysisApprovalStateRetiresAllStalePendingApprovals(t *test }, }) - var latestApproval map[string]any - var latestPendingExecution map[string]any + var latestApprovalState screenAnalysisApprovalState for i := 0; i < 25; i++ { - approvalRequest, pendingExecution, _, err := service.buildScreenAnalysisApprovalState(task) + approvalState, err := service.buildScreenAnalysisApprovalState(task) if err != nil { t.Fatalf("build paginated screen approval state failed at cycle %d: %v", i, err) } - if err := service.persistApprovalRequestState(task.TaskID, approvalRequest, mapValue(pendingExecution, "impact_scope")); err != nil { + if err := service.persistApprovalRequestState(task.TaskID, approvalState.approvalRequestMap(), approvalState.PendingExecution.ImpactScope.mapValue()); err != nil { t.Fatalf("persist paginated screen approval state failed at cycle %d: %v", i, err) } - latestApproval = approvalRequest - latestPendingExecution = pendingExecution + latestApprovalState = approvalState } - if latestApproval == nil || latestPendingExecution == nil { + if latestApprovalState.ApprovalRequest.ApprovalID == "" || latestApprovalState.PendingExecution.Kind == "" { t.Fatal("expected latest approval cycle to be captured") } @@ -9097,7 +8503,7 @@ func TestBuildScreenAnalysisApprovalStateRetiresAllStalePendingApprovals(t *test t.Fatalf("expected every approval cycle to persist, got total=%d items=%+v", total, items) } - activeApprovalID := stringValue(latestApproval, "approval_id", "") + activeApprovalID := latestApprovalState.ApprovalRequest.ApprovalID pendingCount := 0 for _, item := range items { if item.Status != "pending" { @@ -9124,22 +8530,13 @@ func TestServiceStartTaskPreservesClipCaptureModeThroughScreenApproval(t *testin t.Fatalf("write clip input failed: %v", err) } - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_clip_task", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请分析录屏里的错误", - }, - "intent": map[string]any{ - "name": "screen_analyze", - "arguments": map[string]any{ - "path": "inputs/screen.webm", - "capture_mode": string(tools.ScreenCaptureModeClip), - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_clip_task", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请分析录屏里的错误"}, Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "inputs/screen.webm", + "capture_mode": string(tools.ScreenCaptureModeClip), }, - }) + }}) if err != nil { t.Fatalf("start clip screen analyze task failed: %v", err) } @@ -9180,25 +8577,7 @@ func TestServiceStartTaskInfersScreenAnalyzeFromVisualErrorRequest(t *testing.T) ocrStub := stubOCRWorkerClient{result: tools.OCRTextResult{Path: "temp/screen_local_0001/frame_0001.png", Text: "fatal build error", Language: "eng", Source: "ocr_worker_text"}} service, _ := newTestServiceWithExecutionWorkers(t, "unused", platform.LocalExecutionBackend{}, nil, sidecarclient.NewNoopPlaywrightSidecarClient(), ocrStub, sidecarclient.NewNoopMediaWorkerClient()) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_infer_start", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "帮我看看这个页面的报错", - "page_context": map[string]any{ - "title": "Build Dashboard", - "url": "https://example.com/build", - "app_name": "Chrome", - "window_title": "Browser - Build Dashboard", - "visible_text": "Fatal build error: missing release asset", - }, - }, - "context": map[string]any{ - "screen_summary": "release validation failed on current screen", - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_infer_start", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "帮我看看这个页面的报错", PageContext: &PageContext{Title: "Build Dashboard", URL: "https://example.com/build", AppName: "Chrome", WindowTitle: "Browser - Build Dashboard", VisibleText: "Fatal build error: missing release asset"}}, Context: &InputContext{ScreenSummary: "release validation failed on current screen"}}) if err != nil { t.Fatalf("start inferred screen analyze task failed: %v", err) } @@ -9268,28 +8647,12 @@ func TestServiceStartTaskExplicitScreenAnalyzeKeepsFreshAuthorizationBoundary(t }, }) - result, err := service.StartTask(map[string]any{ - "session_id": activeTask.SessionID, - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请分析当前屏幕里的错误", - }, - "context": map[string]any{ - "page": map[string]any{ - "url": "https://example.com/build/1", - "app_name": "Chrome", - "window_title": "Build 1", - }, - }, - "intent": map[string]any{ - "name": "screen_analyze", - "arguments": map[string]any{ - "path": "inputs/screen.png", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: activeTask.SessionID, Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请分析当前屏幕里的错误"}, Context: &InputContext{Page: &PageContext{URL: "https://example.com/build/1", AppName: "Chrome", WindowTitle: "Build 1"}}, Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "inputs/screen.png", }, - }) + }}) if err != nil { t.Fatalf("start explicit screen analyze task failed: %v", err) } @@ -9342,21 +8705,12 @@ func TestServiceStartTaskHandlesClipScreenAnalyzePath(t *testing.T) { t.Fatalf("write clip source failed: %v", err) } - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_clip_start", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请分析这段录屏", - }, - "intent": map[string]any{ - "name": "screen_analyze", - "arguments": map[string]any{ - "path": "clips/demo.webm", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_clip_start", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请分析这段录屏"}, Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "clips/demo.webm", }, - }) + }}) if err != nil { t.Fatalf("start clip screen analyze task failed: %v", err) } @@ -9391,7 +8745,7 @@ func TestServiceStartTaskHandlesClipScreenAnalyzePath(t *testing.T) { if payload["capture_mode"] != "clip" || payload["screen_session_id"] == "" { t.Fatalf("expected clip artifact payload to preserve clip capture metadata, got %+v", payload) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get for clip screen task failed: %v", err) } @@ -9415,21 +8769,12 @@ func TestServiceScreenAnalyzeStopsSessionAfterSuccessfulApproval(t *testing.T) { ocrStub := stubOCRWorkerClient{result: tools.OCRTextResult{Path: "temp/screen_sess_0001/frame_0001.png", Text: "fatal build error", Language: "eng", Source: "ocr_worker_text"}} service, _ := newTestServiceWithExecutionWorkersAndScreen(t, "unused", platform.LocalExecutionBackend{}, nil, sidecarclient.NewNoopPlaywrightSidecarClient(), ocrStub, sidecarclient.NewNoopMediaWorkerClient(), screenClient) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_stop", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请分析屏幕中的错误", - }, - "intent": map[string]any{ - "name": "screen_analyze", - "arguments": map[string]any{ - "path": "inputs/screen.png", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_stop", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请分析屏幕中的错误"}, Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "inputs/screen.png", }, - }) + }}) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -9467,21 +8812,12 @@ func TestServiceScreenAnalyzeFailureExpiresAndCleansSession(t *testing.T) { ocrStub := stubOCRWorkerClient{err: tools.ErrOCRWorkerFailed} service, _ := newTestServiceWithExecutionWorkersAndScreen(t, "unused", platform.LocalExecutionBackend{}, nil, sidecarclient.NewNoopPlaywrightSidecarClient(), ocrStub, sidecarclient.NewNoopMediaWorkerClient(), screenClient) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_cleanup", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请分析屏幕中的错误", - }, - "intent": map[string]any{ - "name": "screen_analyze", - "arguments": map[string]any{ - "path": "inputs/screen.png", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_cleanup", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请分析屏幕中的错误"}, Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "inputs/screen.png", }, - }) + }}) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -9517,21 +8853,12 @@ func TestServiceScreenAnalyzeCaptureFailureExpiresAndCleansSession(t *testing.T) screenClient := &recordingScreenCaptureClient{base: sidecarclient.NewInMemoryScreenCaptureClient(), captureErr: tools.ErrScreenCaptureFailed} service, _ := newTestServiceWithExecutionWorkersAndScreen(t, "unused", platform.LocalExecutionBackend{}, nil, sidecarclient.NewNoopPlaywrightSidecarClient(), sidecarclient.NewNoopOCRWorkerClient(), sidecarclient.NewNoopMediaWorkerClient(), screenClient) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_capture_failure", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请分析屏幕中的错误", - }, - "intent": map[string]any{ - "name": "screen_analyze", - "arguments": map[string]any{ - "path": "inputs/screen.png", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_capture_failure", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请分析屏幕中的错误"}, Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "inputs/screen.png", }, - }) + }}) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -9564,21 +8891,7 @@ func TestServiceStartTaskInfersScreenAnalyzeTitleFromScreenSummaryWhenTitlesAreM ocrStub := stubOCRWorkerClient{result: tools.OCRTextResult{Path: "temp/screen_local_0001/frame_0001.png", Text: "fatal build error", Language: "eng", Source: "ocr_worker_text"}} service, _ := newTestServiceWithExecutionWorkers(t, "unused", platform.LocalExecutionBackend{}, nil, sidecarclient.NewNoopPlaywrightSidecarClient(), ocrStub, sidecarclient.NewNoopMediaWorkerClient()) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_summary_title", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "看看当前屏幕上哪里出错了", - }, - "context": map[string]any{ - "screen": map[string]any{ - "summary": "release validation failed before publish", - "visible_text": "fatal build error", - }, - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_summary_title", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "看看当前屏幕上哪里出错了"}, Context: &InputContext{Screen: &ScreenContext{Summary: "release validation failed before publish", VisibleText: "fatal build error"}}}) if err != nil { t.Fatalf("start inferred screen task failed: %v", err) } @@ -9598,26 +8911,7 @@ func TestServiceSubmitInputInfersScreenAnalyzeFromVisualErrorRequest(t *testing. ocrStub := stubOCRWorkerClient{result: tools.OCRTextResult{Path: "temp/screen_local_0001/frame_0001.png", Text: "fatal build error", Language: "eng", Source: "ocr_worker_text"}} service, _ := newTestServiceWithExecutionWorkers(t, "unused", platform.LocalExecutionBackend{}, nil, sidecarclient.NewNoopPlaywrightSidecarClient(), ocrStub, sidecarclient.NewNoopMediaWorkerClient()) - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_screen_infer_submit", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "看看当前屏幕上的报错", - "input_mode": "text", - }, - "context": map[string]any{ - "page": map[string]any{ - "title": "Release Checklist", - "url": "https://example.com/release", - "app_name": "Chrome", - "window_title": "Browser - Release Checklist", - "visible_text": "Warning: release notes are incomplete.", - }, - "screen_summary": "release checklist shows blocking warning", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_screen_infer_submit", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "看看当前屏幕上的报错", InputMode: "text"}, Context: &InputContext{Page: &PageContext{Title: "Release Checklist", URL: "https://example.com/release", AppName: "Chrome", WindowTitle: "Browser - Release Checklist", VisibleText: "Warning: release notes are incomplete."}, ScreenSummary: "release checklist shows blocking warning"}}) if err != nil { t.Fatalf("submit inferred screen analyze task failed: %v", err) } @@ -9634,23 +8928,7 @@ func TestServiceSubmitInputInfersScreenAnalyzeFromVisualErrorRequest(t *testing. func TestServiceStartTaskFallsBackWhenScreenCapabilityUnavailable(t *testing.T) { service := newTestService() - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_capability_fallback", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "帮我看看这个页面的报错", - "page_context": map[string]any{ - "title": "Build Dashboard", - "window_title": "Browser - Build Dashboard", - "visible_text": "Fatal build error: missing release asset", - }, - }, - "context": map[string]any{ - "screen_summary": "release validation failed on current screen", - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_capability_fallback", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "帮我看看这个页面的报错", PageContext: &PageContext{Title: "Build Dashboard", WindowTitle: "Browser - Build Dashboard", VisibleText: "Fatal build error: missing release asset"}}, Context: &InputContext{ScreenSummary: "release validation failed on current screen"}}) if err != nil { t.Fatalf("start task with unavailable screen capability failed: %v", err) } @@ -9673,27 +8951,7 @@ func TestServiceStartTaskFallsBackWhenScreenCapabilityUnavailable(t *testing.T) func TestServiceSubmitInputFallbackKeepsExplicitConfirmationWhenScreenCapabilityUnavailable(t *testing.T) { service := newTestService() - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_screen_capability_confirm_fallback", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "帮我看看这个页面的报错", - "input_mode": "text", - }, - "context": map[string]any{ - "page": map[string]any{ - "title": "Build Dashboard", - "window_title": "Browser - Build Dashboard", - "visible_text": "Fatal build error: missing release asset", - }, - "screen_summary": "release validation failed on current screen", - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_screen_capability_confirm_fallback", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "帮我看看这个页面的报错", InputMode: "text"}, Context: &InputContext{Page: &PageContext{Title: "Build Dashboard", WindowTitle: "Browser - Build Dashboard", VisibleText: "Fatal build error: missing release asset"}, ScreenSummary: "release validation failed on current screen"}, Options: &InputSubmitOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("submit input with unavailable screen capability failed: %v", err) } @@ -9720,21 +8978,12 @@ func TestServiceSubmitInputFallbackKeepsExplicitConfirmationWhenScreenCapability func TestSecurityRespondScreenAnalyzeFailureReconcilesTaskState(t *testing.T) { ocrStub := stubOCRWorkerClient{result: tools.OCRTextResult{Path: "temp/screen_local_0001/frame_0001.png", Text: "fatal build error", Language: "eng", Source: "ocr_worker_text"}} service, _ := newTestServiceWithExecutionWorkers(t, "unused", platform.LocalExecutionBackend{}, nil, sidecarclient.NewNoopPlaywrightSidecarClient(), ocrStub, sidecarclient.NewNoopMediaWorkerClient()) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_screen_fail", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请分析屏幕中的错误", - }, - "intent": map[string]any{ - "name": "screen_analyze", - "arguments": map[string]any{ - "path": "inputs/missing-screen.png", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_screen_fail", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请分析屏幕中的错误"}, Intent: map[string]any{ + "name": "screen_analyze", + "arguments": map[string]any{ + "path": "inputs/missing-screen.png", }, - }) + }}) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -9778,21 +9027,12 @@ func TestServiceStartTaskHitsRealMemoryAndRecordsRetrievalHit(t *testing.T) { t.Fatalf("seed memory summary failed: %v", err) } - result, err := service.StartTask(map[string]any{ - "session_id": "sess_memory_hit", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请按 project alpha markdown bullets 总结这段内容", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_memory_hit", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请按 project alpha markdown bullets 总结这段内容"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -9872,21 +9112,12 @@ func TestServiceStartTaskInjectsRetrievedMemoryIntoExecutionInput(t *testing.T) t.Fatalf("seed memory summary failed: %v", err) } - _, err := service.StartTask(map[string]any{ - "session_id": "sess_memory_context", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请按 project alpha markdown bullets 总结这段内容", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + _, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_memory_context", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请按 project alpha markdown bullets 总结这段内容"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -9937,15 +9168,7 @@ func TestServiceConfirmTaskPersistsRetrievalHitOncePerConfirmation(t *testing.T) t.Fatalf("seed memory summary failed: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_confirm_memory_hit", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "请解释 project alpha 的输出格式", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_confirm_memory_hit", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "请解释 project alpha 的输出格式"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -10023,40 +9246,22 @@ func TestServiceMirrorOverviewFallsBackToStoredFinishedTasks(t *testing.T) { func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { service := newTestService() - _, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "completed task for security summary restore point", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + _, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "completed task for security summary restore point"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start completed task failed: %v", err) } - waitingResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "security summary intercepted task", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + waitingResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "security summary intercepted task"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start intercepted task failed: %v", err) } @@ -10097,21 +9302,12 @@ func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { func TestServiceSecuritySummaryIncludesRuntimeTokenUsage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "executor-backed token summary") - result, err := service.StartTask(map[string]any{ - "session_id": "sess_exec", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "collect runtime token summary", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "collect runtime token summary"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -10154,19 +9350,7 @@ func TestServiceBudgetAutoDowngradeSwitchesWorkspaceDeliveryToBubble(t *testing. }) defer unsubscribe() - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_budget_downgrade", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": strings.Repeat("long task content ", 20), - }, - "options": map[string]any{ - "confirm_required": false, - "preferred_delivery": "workspace_document", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_budget_downgrade", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: strings.Repeat("long task content ", 20)}, Options: &InputSubmitOptions{ConfirmRequired: false, PreferredDelivery: "workspace_document"}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -10321,19 +9505,7 @@ func TestServiceBudgetAutoDowngradeDisabledKeepsWorkspaceDelivery(t *testing.T) t.Fatalf("settings update failed: %v", err) } - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_budget_disabled", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": strings.Repeat("long task content ", 20), - }, - "options": map[string]any{ - "confirm_required": false, - "preferred_delivery": "workspace_document", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_budget_disabled", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: strings.Repeat("long task content ", 20)}, Options: &InputSubmitOptions{ConfirmRequired: false, PreferredDelivery: "workspace_document"}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -10466,18 +9638,7 @@ func TestExecutionFailureBubbleRedactsSecretBearingProviderMessages(t *testing.T func TestServiceBudgetFallbackSuccessStillAppendsFailureSignal(t *testing.T) { service, _ := newTestServiceWithExecution(t, "executor-backed fallback success signal") - result, err := service.SubmitInput(map[string]any{ - "session_id": "sess_budget_fallback_signal", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": strings.Repeat("fallback signal content ", 12), - }, - "options": map[string]any{ - "confirm_required": false, - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: "sess_budget_fallback_signal", Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: strings.Repeat("fallback signal content ", 12)}, Options: &InputSubmitOptions{ConfirmRequired: false}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -10688,21 +9849,12 @@ func TestServiceDashboardModuleCountsRuntimeAndStoredPendingAuthorizations(t *te t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ - "session_id": "sess_dashboard_module_mixed", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "runtime waiting authorization for dashboard module", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + runtimeResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_dashboard_module_mixed", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "runtime waiting authorization for dashboard module"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start runtime waiting task failed: %v", err) } @@ -10759,21 +9911,12 @@ func TestServiceSecuritySummaryCountsRuntimeAndStoredPendingAuthorizations(t *te t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ - "session_id": "sess_security_summary_mixed", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "runtime waiting authorization for security summary", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + runtimeResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_security_summary_mixed", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "runtime waiting authorization for security summary"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start runtime waiting task failed: %v", err) } @@ -10823,21 +9966,12 @@ func TestServiceSecurityPendingListMergesRuntimeAndStoredPendingAuthorizations(t t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ - "session_id": "sess_security_pending_list_mixed", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "runtime waiting authorization for pending list", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + runtimeResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_security_pending_list_mixed", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "runtime waiting authorization for pending list"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start runtime waiting task failed: %v", err) } @@ -10904,21 +10038,12 @@ func TestServiceSecurityPendingListPaginatesMergedPendingAuthorizations(t *testi t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ - "session_id": "sess_security_pending_list_page", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "runtime waiting authorization for pending pagination", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + runtimeResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_security_pending_list_page", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "runtime waiting authorization for pending pagination"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start runtime waiting task failed: %v", err) } @@ -11129,21 +10254,12 @@ func TestServiceSecurityRestoreApplyReturnsStorageErrorWhenApprovalPersistenceFa t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_restore_store_failure", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请覆盖该文件", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": originalPath, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_restore_store_failure", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请覆盖该文件"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": originalPath, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -11170,21 +10286,12 @@ func TestServiceSecurityRestoreApplyReturnsStorageErrorWhenApprovalPersistenceFa func TestServiceDashboardModuleHighlightsIncludeAuditTrail(t *testing.T) { service, _ := newTestServiceWithExecution(t, "dashboard audit trail") - _, err := service.StartTask(map[string]any{ - "session_id": "sess_exec", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "build dashboard audit trail", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + _, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "build dashboard audit trail"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -11471,15 +10578,7 @@ func TestServiceTaskDetailGetFallsBackToStoredRecoveryPointForTask(t *testing.T) t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_exec", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "task detail restore point fallback", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "task detail restore point fallback"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -11495,7 +10594,7 @@ func TestServiceTaskDetailGetFallsBackToStoredRecoveryPointForTask(t *testing.T) t.Fatalf("write recovery point failed: %v", err) } - result, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11512,15 +10611,7 @@ func TestServiceTaskDetailGetDropsMismatchedSummaryRecoveryPointBeforeStorageFal t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_exec_mismatch", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "task detail restore point mismatch fallback", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec_mismatch", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "task detail restore point mismatch fallback"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -11544,7 +10635,7 @@ func TestServiceTaskDetailGetDropsMismatchedSummaryRecoveryPointBeforeStorageFal t.Fatalf("write recovery point failed: %v", err) } - result, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11565,21 +10656,12 @@ func TestServiceSecurityRestoreApplyRestoresWorkspaceAndReturnsFormalResult(t *t t.Fatalf("seed original file: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_exec", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请覆盖该文件", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "notes/output.md", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请覆盖该文件"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "notes/output.md", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -11685,21 +10767,12 @@ func TestServiceSecurityRestoreApplyReturnsStructuredFailure(t *testing.T) { t.Fatalf("seed original file: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_exec", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请覆盖该文件", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "notes/output.md", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请覆盖该文件"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "notes/output.md", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -11777,21 +10850,12 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) t.Fatalf("seed original file: %v", err) } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_exec", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请覆盖该文件", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "notes/output.md", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请覆盖该文件"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "notes/output.md", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -11803,7 +10867,7 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) if respondResult["task"].(map[string]any)["status"] != "completed" { t.Fatalf("expected task to complete before persisted fallback, got %+v", respondResult) } - if _, err := service.TaskDetailGet(map[string]any{"task_id": taskID}); err != nil { + if _, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}); err != nil { t.Fatalf("task detail get before persisted fallback failed: %v", err) } pointsResult, err := service.SecurityRestorePointsList(map[string]any{"task_id": taskID, "limit": 20, "offset": 0}) @@ -11835,21 +10899,13 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) func TestServiceTaskDetailGetPreservesStableContractShape(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail delivery") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_detail", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "collect detail view payload", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "collect detail view payload"}}) if err != nil { t.Fatalf("start task failed: %v", err) } taskID := startResult["task"].(map[string]any)["task_id"].(string) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11883,15 +10939,7 @@ func TestServiceTaskDetailGetPreservesStableContractShape(t *testing.T) { func TestServiceTaskDetailGetNormalizesProtocolCollections(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail protocol collections") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_detail_protocol", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "collect normalized detail payload", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_detail_protocol", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "collect normalized detail payload"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -11923,7 +10971,7 @@ func TestServiceTaskDetailGetNormalizesProtocolCollections(t *testing.T) { t.Fatal("expected mirror reference update to succeed") } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12013,7 +11061,7 @@ func TestServiceTaskDetailGetFallsBackToStoredTaskRun(t *testing.T) { t.Fatalf("save artifact failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": "task_stored_detail"}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: "task_stored_detail"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12088,7 +11136,7 @@ func TestServiceTaskDetailGetFallsBackToTaskRunCitationsForScreenTask(t *testing t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": "task_stored_screen_detail"}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: "task_stored_screen_detail"}) if err != nil { t.Fatalf("task detail get with stored citations failed: %v", err) } @@ -12137,7 +11185,7 @@ func TestServiceTaskDetailGetPrefersStructuredTaskStoreFallback(t *testing.T) { t.Fatalf("replace structured task steps failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": "task_structured_detail"}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: "task_structured_detail"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12201,7 +11249,7 @@ func TestServiceTaskDetailGetUsesStructuredSnapshotWithoutReloadingTaskRuns(t *t t.Fatalf("save task run failed: %v", err) } - result, err := service.TaskDetailGet(map[string]any{"task_id": "task_structured_snapshot"}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: "task_structured_snapshot"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12282,7 +11330,7 @@ func TestServiceTaskDetailGetReloadsTaskRunWhenStructuredSnapshotIsInvalid(t *te t.Fatalf("overwrite structured task failed: %v", err) } - result, err := service.TaskDetailGet(map[string]any{"task_id": "task_structured_invalid_snapshot"}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: "task_structured_invalid_snapshot"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12389,7 +11437,7 @@ func TestServiceTaskDetailGetStructuredFallbackUsesSessionAndRunStores(t *testin }); err != nil { t.Fatalf("write run failed: %v", err) } - result, err := service.TaskDetailGet(map[string]any{"task_id": "task_structured_session_run"}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: "task_structured_session_run"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12439,7 +11487,7 @@ func TestServiceTaskDetailGetPrefersRuntimeStateWhenStructuredRowIsStale(t *test }); err != nil { t.Fatalf("write stale structured task failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": runtimeTask.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: runtimeTask.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12550,7 +11598,7 @@ func TestServiceTaskDetailGetStructuredFallbackBackfillsTaskRunEvidence(t *testi t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12626,7 +11674,7 @@ func TestServiceTaskDetailGetStructuredFallbackReadsFormalDeliveryFromLoopStore( t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12706,7 +11754,7 @@ func TestServiceTaskDetailGetStructuredFallbackReadsFormalCitationsFromLoopStore t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12827,7 +11875,7 @@ func TestServiceTaskDetailGetStructuredFallbackPrefersFirstClassDeliveryAndCitat t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12908,7 +11956,7 @@ func TestServiceTaskDetailGetStructuredFallbackNormalizesSparseDeliveryPayloadKe t.Fatalf("delete sparse runtime task shadow failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13040,7 +12088,7 @@ func TestServiceRestartedStructuredTaskFallsBackToCurrentSnapshotFormalData(t *t t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13138,7 +12186,7 @@ func TestServiceTaskDetailGetStructuredFallbackRehydratesApprovalRequest(t *test t.Fatalf("write approval request failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13225,7 +12273,7 @@ func TestServiceTaskDetailGetStructuredFallbackRehydratesAuthorizationAndAudit(t t.Fatalf("write structured audit record failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13379,7 +12427,7 @@ func TestServiceTaskDetailGetPrefersStoredScreenFormalObjectsOverRuntimeCompatib t.Fatalf("write stored audit record failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": runtimeTask.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: runtimeTask.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13450,7 +12498,7 @@ func TestServiceTaskDetailGetPrefersStoredApprovalRequestOverRuntimeCompatibilit t.Fatalf("write stored approval request failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": runtimeTask.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: runtimeTask.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13604,7 +12652,7 @@ func TestServiceTaskDetailGetStructuredScreenFallbackPrefersFormalEvidenceObject t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13807,7 +12855,7 @@ func TestServiceTaskDetailGetReloadsTaskRunWhenFormalScreenObjectsMaskInvalidSna t.Fatalf("rewrite structured task with invalid snapshot failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13898,7 +12946,7 @@ func TestServiceTaskDetailGetScreenAuditPrefersNewerTerminalGovernanceRecord(t * t.Fatalf("write recovery audit record failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13977,7 +13025,7 @@ func TestServiceTaskDetailGetStructuredScreenApprovalPrefersFormalApprovalReques t.Fatalf("write formal screen approval request failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -14280,21 +13328,12 @@ func TestServiceTaskArtifactOpenPrefersStoredArtifactOverRuntimeCompatibility(t func TestServiceTaskArtifactOpenReturnsArtifactNotFoundWhenTaskExists(t *testing.T) { service, _ := newTestServiceWithExecution(t, "artifact not found") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_artifact_not_found", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请整理成文档", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_artifact_not_found", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请整理成文档"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14310,21 +13349,12 @@ func TestServiceStartTaskPersistsArtifactsToStore(t *testing.T) { if service.storage == nil { t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_artifact_persist", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请整理成文档", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_artifact_persist", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请整理成文档"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14348,21 +13378,12 @@ func TestServiceStartTaskPersistsArtifactsToStore(t *testing.T) { func TestServiceDeliveryOpenReturnsTaskDeliveryResult(t *testing.T) { service, _ := newTestServiceWithExecution(t, "delivery open task") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_delivery_open", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请整理成文档", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_delivery_open", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请整理成文档"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14481,7 +13502,7 @@ func TestServiceResultPageFallbackWithoutFormalDeliveryRowKeepsOpenAndDetailStab t.Fatalf("write structured task failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -14560,7 +13581,7 @@ func TestServiceLegacyResultPageSnapshotOverridesBubbleCompatShape(t *testing.T) t.Fatalf("write structured task failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -14628,7 +13649,7 @@ func TestServiceResultPagePayloadNormalizationOverridesLegacyBadValues(t *testin t.Fatalf("save delivery result failed: %v", err) } - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -14753,21 +13774,12 @@ func TestTaskDeliveryHelpersSupportResultPage(t *testing.T) { func TestServiceTaskArtifactListFallsBackToRuntimeArtifactsWhenStoreEmpty(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_runtime_artifact", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请整理成文档", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_runtime_artifact", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请整理成文档"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14804,18 +13816,9 @@ func TestServiceTaskArtifactListMergesStoredAndRuntimeArtifacts(t *testing.T) { if service.storage == nil || service.storage.ArtifactStore() == nil { t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_merge_artifact", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请整理 artifact 列表", - }, - "intent": map[string]any{ - "name": "summarize", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_merge_artifact", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请整理 artifact 列表"}, Intent: map[string]any{ + "name": "summarize", + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14890,15 +13893,7 @@ func TestServiceTaskArtifactListMergesStoredAndRuntimeArtifacts(t *testing.T) { func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_runtime_missing_artifact_id", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "只检查运行态 artifact id", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_runtime_missing_artifact_id", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "只检查运行态 artifact id"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14918,7 +13913,7 @@ func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t * "delivery_payload": map[string]any{"path": "workspace/runtime-output.txt", "task_id": taskID}, }}) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -14952,15 +13947,7 @@ func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t * func TestServiceTaskControlRejectsInvalidStatusTransition(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "this task still requires confirmation", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "text_selected_click", Input: TaskStartInput{Type: "text_selection", Text: "this task still requires confirmation"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -15714,21 +14701,12 @@ func TestDashboardModuleGetIncludesPluginRuntimeSummary(t *testing.T) { func TestDashboardModuleGetTasksIncludesFocusRuntimeSummary(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_dashboard_tasks_runtime", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "dashboard task runtime summary", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_dashboard_tasks_runtime", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "dashboard task runtime summary"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -15787,21 +14765,12 @@ func TestServiceTaskControlRequiresTaskID(t *testing.T) { func TestServiceTaskControlRequiresAction(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "task control needs action", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "task control needs action"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -15818,21 +14787,12 @@ func TestServiceTaskControlRequiresAction(t *testing.T) { func TestServiceTaskControlRejectsFinishedTaskOperations(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "completed task for task control error mapping", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_demo", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "completed task for task control error mapping"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -15850,21 +14810,12 @@ func TestServiceTaskControlRejectsFinishedTaskOperations(t *testing.T) { func TestServiceTaskControlReturnsUpdatedTaskAndBubbleForWaitingAuthCancel(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_task_control_payload", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "task control should return stable payload", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_task_control_payload", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "task control should return stable payload"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -16209,21 +15160,12 @@ func TestPersistExecutionToolCallEventsFallsBackWhenToolCallIDMissing(t *testing func TestServiceTaskSteerPersistsFollowUpMessage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task steer") - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_task_steer", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Please write this into a file after authorization.", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_task_steer", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Please write this into a file after authorization."}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -16445,16 +15387,7 @@ func TestServiceSubmitInputRoutesFollowUpIntoExistingTask(t *testing.T) { activeTaskID = activeTask.TaskID activeSessionID := activeTask.SessionID - followUpResult, err := service.SubmitInput(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "重点看网络层,不要讲太泛", - "input_mode": "text", - }, - "context": map[string]any{}, - }) + followUpResult, err := submitInputForTest(service, SubmitInputRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "重点看网络层,不要讲太泛", InputMode: "text"}, Context: &InputContext{}}) if err != nil { t.Fatalf("submit follow-up failed: %v", err) } @@ -16486,17 +15419,7 @@ func TestServiceSubmitInputQueuesNewTaskWhenActivePromptTaskCannotConsumeSteerin RiskLevel: "green", }) - result, err := service.SubmitInput(map[string]any{ - "session_id": activeTask.SessionID, - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "再补一版网络影响摘要", - "input_mode": "text", - }, - "context": map[string]any{}, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: activeTask.SessionID, Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "再补一版网络影响摘要", InputMode: "text"}, Context: &InputContext{}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -16519,15 +15442,7 @@ func TestServiceSubmitInputQueuesNewTaskWhenActivePromptTaskCannotConsumeSteerin func TestServiceStartTaskNotificationIncludesSessionID(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "session_id": "sess_notification_contract", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Summarize this update", - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_notification_contract", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Summarize this update"}}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -16583,18 +15498,7 @@ func TestServiceStartTaskDescribedFileDoesNotAttachToProcessingTask(t *testing.T }) activeTaskID = activeTask.TaskID - followUpResult, err := service.StartTask(map[string]any{ - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "text": "重点结合这个日志继续分析", - "files": []string{"logs/network.log"}, - }, - "options": map[string]any{ - "confirm_required": false, - }, - }) + followUpResult, err := startTaskForTest(service, StartTaskRequest{Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Text: "重点结合这个日志继续分析", Files: []string{"logs/network.log"}}, Options: &TaskStartOptions{ConfirmRequired: false}}) if err != nil { t.Fatalf("start file follow-up failed: %v", err) } @@ -16632,19 +15536,7 @@ func TestServiceStartTaskDescribedFileStartsNewTaskWithoutPendingEvidence(t *tes }, }) - result, err := service.StartTask(map[string]any{ - "session_id": waitingTask.SessionID, - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "text": "summarize this attachment", - "files": []string{"logs/network.log"}, - }, - "options": map[string]any{ - "confirm_required": false, - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{SessionID: waitingTask.SessionID, Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Text: "summarize this attachment", Files: []string{"logs/network.log"}}, Options: &TaskStartOptions{ConfirmRequired: false}}) if err != nil { t.Fatalf("start described file task failed: %v", err) } @@ -16683,25 +15575,7 @@ func TestServiceStartTaskShellBallAnchorDoesNotContinuePendingFileTask(t *testin }, }) - result, err := service.StartTask(map[string]any{ - "session_id": waitingTask.SessionID, - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "text": "summarize this attachment", - "files": []string{"logs/network.log"}, - "page_context": map[string]any{ - "app_name": "desktop", - "title": "Quick Intake", - "url": "local://shell-ball", - "window_title": "Shell Ball", - }, - }, - "options": map[string]any{ - "confirm_required": false, - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{SessionID: waitingTask.SessionID, Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Text: "summarize this attachment", Files: []string{"logs/network.log"}, PageContext: &PageContext{AppName: "desktop", Title: "Quick Intake", URL: "local://shell-ball", WindowTitle: "Shell Ball"}}, Options: &TaskStartOptions{ConfirmRequired: false}}) if err != nil { t.Fatalf("start shell-ball described file task failed: %v", err) } @@ -16780,19 +15654,7 @@ func TestServiceStartTaskConfirmRequiredFileDoesNotContinueProcessingTask(t *tes }) activeTaskID = activeTask.TaskID - startResult, err := service.StartTask(map[string]any{ - "session_id": activeTask.SessionID, - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "text": "先让我确认再处理这个文件", - "files": []string{"logs/network.log"}, - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: activeTask.SessionID, Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Text: "先让我确认再处理这个文件", Files: []string{"logs/network.log"}}, Options: &TaskStartOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("start confirm-required file task failed: %v", err) } @@ -16840,19 +15702,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesPendingTask(t *testing.T) RiskLevel: "green", }) - result, err := service.SubmitInput(map[string]any{ - "session_id": activeTask.SessionID, - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Use the latest customer impact numbers.", - "input_mode": "text", - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: activeTask.SessionID, Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Use the latest customer impact numbers.", InputMode: "text"}, Options: &InputSubmitOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("submit confirm-required text follow-up failed: %v", err) } @@ -16900,19 +15750,7 @@ func TestServiceSubmitInputFallsBackWhenContinuationModelTimesOut(t *testing.T) }) start := time.Now() - result, err := service.SubmitInput(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Translate this note into English", - "input_mode": "text", - }, - "options": map[string]any{ - "confirm_required": true, - }, - "context": map[string]any{}, - }) + result, err := submitInputForTest(service, SubmitInputRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Translate this note into English", InputMode: "text"}, Options: &InputSubmitOptions{ConfirmRequired: true}, Context: &InputContext{}}) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -16961,18 +15799,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesImplicitPendingTask(t *te }, }) - result, err := service.SubmitInput(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Use the latest customer impact numbers.", - "input_mode": "text", - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Use the latest customer impact numbers.", InputMode: "text"}, Options: &InputSubmitOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("submit implicit confirm-required text follow-up failed: %v", err) } @@ -17039,16 +15866,7 @@ func TestServiceSubmitInputPlainTextKeepsConfirmingTaskBehindConfirmation(t *tes }, }) - result, err := service.SubmitInput(map[string]any{ - "session_id": activeTask.SessionID, - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Use the latest customer impact numbers.", - "input_mode": "text", - }, - }) + result, err := submitInputForTest(service, SubmitInputRequest{SessionID: activeTask.SessionID, Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "Use the latest customer impact numbers.", InputMode: "text"}}) if err != nil { t.Fatalf("submit plain text follow-up for confirming task failed: %v", err) } @@ -17123,14 +15941,7 @@ func TestServiceStartTaskPlainTextImplicitPendingTaskStartsNewWithoutExplicitCon }) activeTaskID = activeTask.TaskID - result, err := service.StartTask(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Translate this email.", - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "Translate this email."}}) if err != nil { t.Fatalf("start implicit plain text new task failed: %v", err) } @@ -17254,23 +16065,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesWaitingInputTask(t *testing }) activeTaskID = activeTask.TaskID - startResult, err := service.StartTask(map[string]any{ - "session_id": activeTask.SessionID, - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "files": []string{"logs/network.log"}, - "page_context": map[string]any{ - "app_name": "Chrome", - "title": "Build Dashboard", - "url": "https://example.com/build", - }, - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: activeTask.SessionID, Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Files: []string{"logs/network.log"}, PageContext: &PageContext{AppName: "Chrome", Title: "Build Dashboard", URL: "https://example.com/build"}}, Options: &TaskStartOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("continue waiting-input file task failed: %v", err) } @@ -17331,24 +16126,7 @@ func TestServiceStartTaskStructuredSupplementContinuesPendingTaskWithoutAutoExec }, }) - startResult, err := service.StartTask(map[string]any{ - "session_id": activeTask.SessionID, - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "text": "Use this log as supporting evidence.", - "files": []string{"logs/network.log"}, - "page_context": map[string]any{ - "app_name": "Chrome", - "title": "Build Dashboard", - "url": "https://example.com/build", - }, - }, - "options": map[string]any{ - "confirm_required": false, - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: activeTask.SessionID, Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Text: "Use this log as supporting evidence.", Files: []string{"logs/network.log"}, PageContext: &PageContext{AppName: "Chrome", Title: "Build Dashboard", URL: "https://example.com/build"}}, Options: &TaskStartOptions{ConfirmRequired: false}}) if err != nil { t.Fatalf("continue structured supplement failed: %v", err) } @@ -17393,23 +16171,7 @@ func TestServiceStartTaskStructuredSupplementResumesWaitingTaskWithConfirmedInte }, }) - startResult, err := service.StartTask(map[string]any{ - "session_id": activeTask.SessionID, - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "files": []string{"logs/network.log"}, - "page_context": map[string]any{ - "app_name": "Chrome", - "title": "Build Dashboard", - "url": "https://example.com/build", - }, - }, - "options": map[string]any{ - "confirm_required": false, - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: activeTask.SessionID, Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Files: []string{"logs/network.log"}, PageContext: &PageContext{AppName: "Chrome", Title: "Build Dashboard", URL: "https://example.com/build"}}, Options: &TaskStartOptions{ConfirmRequired: false}}) if err != nil { t.Fatalf("resume structured supplement failed: %v", err) } @@ -17479,24 +16241,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesUniquePendingTaskAmongCandi }, }) - startResult, err := service.StartTask(map[string]any{ - "session_id": targetTask.SessionID, - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "files": []string{"logs/network.log"}, - "page_context": map[string]any{ - "app_name": "Chrome", - "title": "Build Dashboard", - "url": "https://example.com/build", - "window_title": "Browser - Build Dashboard", - }, - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: targetTask.SessionID, Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Files: []string{"logs/network.log"}, PageContext: &PageContext{AppName: "Chrome", Title: "Build Dashboard", URL: "https://example.com/build", WindowTitle: "Browser - Build Dashboard"}}, Options: &TaskStartOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("continue unique multi-candidate file task failed: %v", err) } @@ -17554,23 +16299,7 @@ func TestServiceStartTaskConfirmRequiredFileStartsNewTaskWithoutPendingEvidence( }) activeTaskID = activeTask.TaskID - startResult, err := service.StartTask(map[string]any{ - "session_id": activeTask.SessionID, - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "files": []string{"logs/network.log"}, - "page_context": map[string]any{ - "app_name": "desktop", - "title": "Quick Intake", - "url": "local://shell-ball", - }, - }, - "options": map[string]any{ - "confirm_required": true, - }, - }) + startResult, err := startTaskForTest(service, StartTaskRequest{SessionID: activeTask.SessionID, Source: "floating_ball", Trigger: "file_drop", Input: TaskStartInput{Type: "file", Files: []string{"logs/network.log"}, PageContext: &PageContext{AppName: "desktop", Title: "Quick Intake", URL: "local://shell-ball"}}, Options: &TaskStartOptions{ConfirmRequired: true}}) if err != nil { t.Fatalf("start unanchored confirm-required file task failed: %v", err) } @@ -17596,35 +16325,18 @@ func TestServiceStartTaskConfirmRequiredFileStartsNewTaskWithoutPendingEvidence( func TestServiceSubmitInputDoesNotContinueWaitingAuthorizationTask(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "把这段报错分析一下", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + startResult, err := startTaskForTest(service, StartTaskRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "把这段报错分析一下"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start waiting_auth task failed: %v", err) } firstTask := startResult["task"].(map[string]any) - followUpResult, err := service.SubmitInput(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "重点看网络层,不要讲太泛", - "input_mode": "text", - }, - "context": map[string]any{}, - }) + followUpResult, err := submitInputForTest(service, SubmitInputRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "重点看网络层,不要讲太泛", InputMode: "text"}, Context: &InputContext{}}) if err != nil { t.Fatalf("submit follow-up after waiting_auth failed: %v", err) } @@ -17652,16 +16364,7 @@ func TestServiceSubmitInputDoesNotContinuePausedTask(t *testing.T) { t.Fatalf("pause task failed: %v", err) } - followUpResult, err := service.SubmitInput(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "重点看网络层,不要讲太泛", - "input_mode": "text", - }, - "context": map[string]any{}, - }) + followUpResult, err := submitInputForTest(service, SubmitInputRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "重点看网络层,不要讲太泛", InputMode: "text"}, Context: &InputContext{}}) if err != nil { t.Fatalf("submit follow-up after pause failed: %v", err) } @@ -17683,20 +16386,12 @@ func TestServiceStartTaskWithExplicitIntentDoesNotReuseWaitingTaskWithoutAnchors RiskLevel: "green", }) - result, err := service.StartTask(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "顺便帮我写一份周报", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "target_path": "workspace/reports/weekly.md", - }, + result, err := startTaskForTest(service, StartTaskRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "顺便帮我写一份周报"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "target_path": "workspace/reports/weekly.md", }, - }) + }}) if err != nil { t.Fatalf("start explicit new task failed: %v", err) } @@ -17726,35 +16421,18 @@ func TestServiceSubmitInputStartsNewTaskForUnrelatedRequest(t *testing.T) { }, }) - firstResult, err := service.StartTask(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "帮我整理这份会议纪要并输出成文档", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + firstResult, err := startTaskForTest(service, StartTaskRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "帮我整理这份会议纪要并输出成文档"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("first task failed: %v", err) } firstTask := firstResult["task"].(map[string]any) - secondResult, err := service.SubmitInput(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "顺便再帮我写一份周报", - "input_mode": "text", - }, - "context": map[string]any{}, - }) + secondResult, err := submitInputForTest(service, SubmitInputRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: InputSubmitInput{Type: "text", Text: "顺便再帮我写一份周报", InputMode: "text"}, Context: &InputContext{}}) if err != nil { t.Fatalf("second task failed: %v", err) } @@ -17777,20 +16455,12 @@ func TestServiceReusesRecentIdleSessionForNewTopLevelTask(t *testing.T) { RiskLevel: "green", }) - result, err := service.StartTask(map[string]any{ - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "帮我重新整理另外一份纪要", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + result, err := startTaskForTest(service, StartTaskRequest{Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "帮我重新整理另外一份纪要"}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task after idle session failed: %v", err) } @@ -17803,21 +16473,12 @@ func TestServiceReusesRecentIdleSessionForNewTopLevelTask(t *testing.T) { func TestServiceTaskListIncludesLoopStopReason(t *testing.T) { service := newTestService() for index := 0; index < 2; index++ { - _, err := service.StartTask(map[string]any{ - "session_id": fmt.Sprintf("sess_loop_stop_%02d", index), - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": fmt.Sprintf("task %02d", index), - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + _, err := startTaskForTest(service, StartTaskRequest{SessionID: fmt.Sprintf("sess_loop_stop_%02d", index), Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: fmt.Sprintf("task %02d", index)}, Intent: map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, }, - }) + }}) if err != nil { t.Fatalf("start task %d failed: %v", index, err) } @@ -18125,21 +16786,12 @@ func TestServiceTaskListDoesNotDoubleCountRuntimeTasksBackedByLegacyRows(t *test func TestServiceStartTaskWithExecutorWritesWorkspaceDocument(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "第一点\n第二点\n第三点") - result, err := service.StartTask(map[string]any{ - "session_id": "sess_exec", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请整理成文档", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请整理成文档"}, Intent: map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -18187,19 +16839,10 @@ func TestServiceStartTaskWithExecutorWritesWorkspaceDocument(t *testing.T) { func TestServiceStartTaskWithExecutorReturnsGeneratedBubble(t *testing.T) { service, _ := newTestServiceWithExecution(t, "这段内容主要在解释当前问题的原因和处理方向。") - result, err := service.StartTask(map[string]any{ - "session_id": "sess_exec", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请解释这段内容", - }, - "intent": map[string]any{ - "name": "explain", - "arguments": map[string]any{}, - }, - }) + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_exec", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请解释这段内容"}, Intent: map[string]any{ + "name": "explain", + "arguments": map[string]any{}, + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -18238,21 +16881,12 @@ func TestServiceStartTaskWithExecutorDeliversPageReadResultPage(t *testing.T) { Source: "playwright_sidecar", }}) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_page_read", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请读取这个网页", - }, - "intent": map[string]any{ - "name": "page_read", - "arguments": map[string]any{ - "url": "https://example.com", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_page_read", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请读取这个网页"}, Intent: map[string]any{ + "name": "page_read", + "arguments": map[string]any{ + "url": "https://example.com", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -18310,23 +16944,14 @@ func TestServiceStartTaskWithExecutorDeliversPageSearchResultPage(t *testing.T) Source: "playwright_sidecar", }}) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_page_search", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请搜索这个网页", - }, - "intent": map[string]any{ - "name": "page_search", - "arguments": map[string]any{ - "url": "https://example.com", - "query": "beta", - "limit": 2, - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_page_search", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请搜索这个网页"}, Intent: map[string]any{ + "name": "page_search", + "arguments": map[string]any{ + "url": "https://example.com", + "query": "beta", + "limit": 2, }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -18357,21 +16982,12 @@ func TestServiceStartTaskWithExecutorDeliversPageSearchResultPage(t *testing.T) func TestServiceWorkerToolWritesToolCallEventNotification(t *testing.T) { service, _ := newTestServiceWithExecutionWorkers(t, "unused", platform.LocalExecutionBackend{}, nil, sidecarclient.NewNoopPlaywrightSidecarClient(), stubOCRWorkerClient{result: tools.OCRTextResult{Path: "notes/demo.txt", Text: "hello from ocr", Language: "plain_text", PageCount: 1, Source: "ocr_worker_text"}}, sidecarclient.NewNoopMediaWorkerClient()) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_ocr_extract", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请提取文本", - }, - "intent": map[string]any{ - "name": "extract_text", - "arguments": map[string]any{ - "path": "notes/demo.txt", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_ocr_extract", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请提取文本"}, Intent: map[string]any{ + "name": "extract_text", + "arguments": map[string]any{ + "path": "notes/demo.txt", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -18406,23 +17022,14 @@ func TestServiceWorkerToolWritesToolCallEventNotification(t *testing.T) { func TestServiceMediaWorkerPropagatesArtifactsAndWorkerEventPayload(t *testing.T) { service, _ := newTestServiceWithExecutionWorkers(t, "unused", platform.LocalExecutionBackend{}, nil, sidecarclient.NewNoopPlaywrightSidecarClient(), sidecarclient.NewNoopOCRWorkerClient(), stubMediaWorkerClient{transcodeResult: tools.MediaTranscodeResult{InputPath: "clips/demo.mov", OutputPath: "clips/demo.mp4", Format: "mp4", Source: "media_worker_ffmpeg"}}) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_media_transcode", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请转码视频", - }, - "intent": map[string]any{ - "name": "transcode_media", - "arguments": map[string]any{ - "path": "clips/demo.mov", - "output_path": "clips/demo.mp4", - "format": "mp4", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_media_transcode", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请转码视频"}, Intent: map[string]any{ + "name": "transcode_media", + "arguments": map[string]any{ + "path": "clips/demo.mov", + "output_path": "clips/demo.mp4", + "format": "mp4", }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -18461,21 +17068,12 @@ func TestServiceMediaWorkerPropagatesArtifactsAndWorkerEventPayload(t *testing.T func TestServiceStartTaskWithExecutorPageReadFailureUsesUnifiedError(t *testing.T) { service, _ := newTestServiceWithExecutionAndPlaywright(t, "unused", platform.LocalExecutionBackend{}, nil, stubPlaywrightClient{err: tools.ErrPlaywrightSidecarFailed}) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_page_read_fail", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请读取这个网页", - }, - "intent": map[string]any{ - "name": "page_read", - "arguments": map[string]any{ - "url": "https://example.com", - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_page_read_fail", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请读取这个网页"}, Intent: map[string]any{ + "name": "page_read", + "arguments": map[string]any{ + "url": "https://example.com", }, - }) + }}) if err != nil { t.Fatalf("start task should return task-centric failure result, got %v", err) } @@ -18529,21 +17127,12 @@ func TestServiceStartTaskWithRealLocalPageReadDelivery(t *testing.T) { }() service, _ := newTestServiceWithExecutionAndPlaywright(t, "unused", platform.LocalExecutionBackend{}, nil, localHTTPPlaywrightClient{}) - result, err := service.StartTask(map[string]any{ - "session_id": "sess_real_page_read", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "请读取本地网页", - }, - "intent": map[string]any{ - "name": "page_read", - "arguments": map[string]any{ - "url": "http://" + listener.Addr().String(), - }, + result, err := startTaskForTest(service, StartTaskRequest{SessionID: "sess_real_page_read", Source: "floating_ball", Trigger: "hover_text_input", Input: TaskStartInput{Type: "text", Text: "请读取本地网页"}, Intent: map[string]any{ + "name": "page_read", + "arguments": map[string]any{ + "url": "http://" + listener.Addr().String(), }, - }) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } diff --git a/services/local-service/internal/orchestrator/social_chat.go b/services/local-service/internal/orchestrator/social_chat.go index 30afee140..334951c36 100644 --- a/services/local-service/internal/orchestrator/social_chat.go +++ b/services/local-service/internal/orchestrator/social_chat.go @@ -134,14 +134,10 @@ func extractJSONObject(raw string) string { return trimmed[start : end+1] } -func (s *Service) socialChatInputResponse(decision inputRouteDecision) map[string]any { +func (s *Service) socialChatInputResponse(decision inputRouteDecision) (TaskEntryResponse, error) { createdAt := time.Now().Format(dateTimeLayout) bubble := s.delivery.BuildBubbleMessage("", "result", decision.Reply, createdAt) - return map[string]any{ - "task": nil, - "bubble_message": bubble, - "delivery_result": nil, - } + return buildTaskEntryResponse(nil, bubble, nil) } func applyInputRouteDecision(suggestion intent.Suggestion, decision inputRouteDecision) intent.Suggestion { diff --git a/services/local-service/internal/orchestrator/task_confirm.go b/services/local-service/internal/orchestrator/task_confirm.go index 0329775d7..d218546b4 100644 --- a/services/local-service/internal/orchestrator/task_confirm.go +++ b/services/local-service/internal/orchestrator/task_confirm.go @@ -65,20 +65,20 @@ func (s *Service) ConfirmTask(params map[string]any) (map[string]any, error) { } else { return nil, ErrTaskNotFound } - return map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": bubble, - "delivery_result": nil, - }, nil + response, err := buildTaskEntryResponse(&updatedTask, bubble, nil) + if err != nil { + return nil, err + } + return response.Map(), nil } if strings.TrimSpace(stringValue(intentValue, "name", "")) == "" { bubble := s.delivery.BuildBubbleMessage(task.TaskID, "status", presentation.Text(presentation.MessageBubbleConfirmMissingIntent, nil), task.UpdatedAt.Format(dateTimeLayout)) if updatedTask, ok := s.runEngine.SetPresentation(task.TaskID, bubble, nil, nil); ok { - return map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": bubble, - "delivery_result": nil, - }, nil + response, err := buildTaskEntryResponse(&updatedTask, bubble, nil) + if err != nil { + return nil, err + } + return response.Map(), nil } return nil, ErrTaskNotFound } @@ -95,18 +95,18 @@ func (s *Service) ConfirmTask(params map[string]any) (map[string]any, error) { if queuedTask, queueBubble, queued, queueErr := s.queueTaskIfSessionBusy(updatedTask); queueErr != nil { return nil, queueErr } else if queued { - return map[string]any{ - "task": taskMap(queuedTask), - "bubble_message": queueBubble, - "delivery_result": nil, - }, nil + response, err := buildTaskEntryResponse(&queuedTask, queueBubble, nil) + if err != nil { + return nil, err + } + return response.Map(), nil } governedTask, governedResponse, handled, governanceErr := s.handleTaskGovernanceDecision(updatedTask, intentValue) if governanceErr != nil { return nil, governanceErr } if handled { - return governedResponse, nil + return governedResponse.Map(), nil } updatedTask = governedTask @@ -121,11 +121,11 @@ func (s *Service) ConfirmTask(params map[string]any) (map[string]any, error) { return nil, err } - return map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": resultBubble, - "delivery_result": optionalFormalDeliveryResult(deliveryResult), - }, nil + response, err := buildTaskEntryResponse(&updatedTask, resultBubble, deliveryResult) + if err != nil { + return nil, err + } + return response.Map(), nil } func (s *Service) revertTaskToIntentConfirmation(task runengine.TaskRecord) (runengine.TaskRecord, error) { diff --git a/services/local-service/internal/orchestrator/task_continuation.go b/services/local-service/internal/orchestrator/task_continuation.go index 4e713cde7..ca0a58d18 100644 --- a/services/local-service/internal/orchestrator/task_continuation.go +++ b/services/local-service/internal/orchestrator/task_continuation.go @@ -40,18 +40,18 @@ type taskContinuationOptions struct { ForceConfirmRequired bool } -func (s *Service) maybeContinueExistingTask(params map[string]any, snapshot taskcontext.TaskContextSnapshot, explicitIntent map[string]any, options taskContinuationOptions) (map[string]any, bool, string, error) { +func (s *Service) maybeContinueExistingTask(params map[string]any, snapshot taskcontext.TaskContextSnapshot, explicitIntent map[string]any, options taskContinuationOptions) (TaskEntryResponse, bool, string, error) { explicitSessionID := strings.TrimSpace(stringValue(params, "session_id", "")) continuationContext := s.resolveTaskContinuationContext(explicitSessionID) decision := s.classifyTaskContinuation(snapshot, explicitIntent, continuationContext, options) if decision.Decision == "continue" && strings.TrimSpace(decision.TaskID) != "" { task, ok := s.loadTaskForContinuation(decision.TaskID) if !ok { - return nil, false, explicitSessionID, nil + return TaskEntryResponse{}, false, explicitSessionID, nil } response, err := s.continueTask(task, snapshot, explicitIntent, decision, options) if err != nil { - return nil, false, explicitSessionID, err + return TaskEntryResponse{}, false, explicitSessionID, err } return response, true, task.SessionID, nil } @@ -60,9 +60,9 @@ func (s *Service) maybeContinueExistingTask(params map[string]any, snapshot task // decision is "new_task", the backend should open a fresh hidden session so // unrelated work does not get serialized behind the old task queue. if strings.TrimSpace(continuationContext.SessionID) != "" && (strings.TrimSpace(explicitSessionID) != "" || continuationContext.SessionMode == "implicit_idle") { - return nil, false, continuationContext.SessionID, nil + return TaskEntryResponse{}, false, continuationContext.SessionID, nil } - return nil, false, "", nil + return TaskEntryResponse{}, false, "", nil } func (s *Service) continuationCandidates(sessionID string) []runengine.TaskRecord { @@ -677,7 +677,7 @@ func nonEmptyAndDifferent(left, right string) bool { return left != "" && right != "" && left != right } -func (s *Service) continueTask(task runengine.TaskRecord, snapshot taskcontext.TaskContextSnapshot, explicitIntent map[string]any, decision taskContinuationDecision, options taskContinuationOptions) (map[string]any, error) { +func (s *Service) continueTask(task runengine.TaskRecord, snapshot taskcontext.TaskContextSnapshot, explicitIntent map[string]any, decision taskContinuationDecision, options taskContinuationOptions) (TaskEntryResponse, error) { if task.Status == "waiting_input" || task.Status == "confirming_intent" { return s.continuePendingTask(task, snapshot, explicitIntent, options) } @@ -690,16 +690,12 @@ func (s *Service) continueTask(task runengine.TaskRecord, snapshot taskcontext.T SteeringMessage: buildTaskContinuationInstruction(continuationSnapshot, explicitIntent), }) if !changed { - return nil, ErrTaskNotFound + return TaskEntryResponse{}, ErrTaskNotFound } - return map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": bubble, - "delivery_result": nil, - }, nil + return buildTaskEntryResponse(&updatedTask, bubble, nil) } -func (s *Service) continuePendingTask(task runengine.TaskRecord, snapshot taskcontext.TaskContextSnapshot, explicitIntent map[string]any, options taskContinuationOptions) (map[string]any, error) { +func (s *Service) continuePendingTask(task runengine.TaskRecord, snapshot taskcontext.TaskContextSnapshot, explicitIntent map[string]any, options taskContinuationOptions) (TaskEntryResponse, error) { baseSnapshot := snapshotFromTask(task) continuationSnapshot := sanitizeContinuationUpdateSnapshot(baseSnapshot, snapshot) mergedSnapshot := mergeContinuationSnapshots(baseSnapshot, continuationSnapshot) @@ -712,13 +708,9 @@ func (s *Service) continuePendingTask(task runengine.TaskRecord, snapshot taskco BubbleMessage: bubble, }) if !changed { - return nil, ErrTaskNotFound + return TaskEntryResponse{}, ErrTaskNotFound } - return map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": bubble, - "delivery_result": nil, - }, nil + return buildTaskEntryResponse(&updatedTask, bubble, nil) } confirmRequired := pendingContinuationRequiresConfirm(task, continuationSnapshot, options) @@ -740,32 +732,24 @@ func (s *Service) continuePendingTask(task runengine.TaskRecord, snapshot taskco BubbleMessage: bubble, }) if !changed { - return nil, ErrTaskNotFound + return TaskEntryResponse{}, ErrTaskNotFound } if suggestion.RequiresConfirm { - return map[string]any{ - "task": taskMap(updatedTask), - "bubble_message": bubble, - "delivery_result": nil, - }, nil + return buildTaskEntryResponse(&updatedTask, bubble, nil) } governedTask, governedResponse, handled, governanceErr := s.handleTaskGovernanceDecision(updatedTask, suggestion.Intent) if governanceErr != nil { - return nil, governanceErr + return TaskEntryResponse{}, governanceErr } if handled { return governedResponse, nil } executedTask, resultBubble, deliveryResult, _, execErr := s.executeTask(governedTask, mergedSnapshot, suggestion.Intent) if execErr != nil { - return nil, execErr + return TaskEntryResponse{}, execErr } - return map[string]any{ - "task": taskMap(executedTask), - "bubble_message": resultBubble, - "delivery_result": deliveryResult, - }, nil + return buildTaskEntryResponse(&executedTask, resultBubble, deliveryResult) } func pendingContinuationRequiresConfirm(task runengine.TaskRecord, snapshot taskcontext.TaskContextSnapshot, options taskContinuationOptions) bool { diff --git a/services/local-service/internal/orchestrator/task_control.go b/services/local-service/internal/orchestrator/task_control.go index a39a525c4..879adde79 100644 --- a/services/local-service/internal/orchestrator/task_control.go +++ b/services/local-service/internal/orchestrator/task_control.go @@ -171,7 +171,7 @@ func (s *Service) advanceRestartedTaskAttempt(previousTask, task runengine.TaskR return runengine.TaskRecord{}, nil, governanceErr } if handled { - bubble := mapValue(governedResponse, "bubble_message") + bubble := mapValue(governedResponse.Map(), "bubble_message") if len(bubble) == 0 { bubble = governedTask.BubbleMessage } diff --git a/services/local-service/internal/orchestrator/task_delivery.go b/services/local-service/internal/orchestrator/task_delivery.go index 75837899d..deae4fbc3 100644 --- a/services/local-service/internal/orchestrator/task_delivery.go +++ b/services/local-service/internal/orchestrator/task_delivery.go @@ -151,14 +151,6 @@ func inferArtifactDeliveryType(artifact map[string]any) string { return "task_detail" } -// protocolTaskStepList guarantees that task detail timeline stays an array. -func protocolTaskStepList(steps []map[string]any) []map[string]any { - if len(steps) == 0 { - return []map[string]any{} - } - return cloneMapSlice(steps) -} - // protocolArtifactList trims artifact items to the declared protocol fields and // keeps the collection non-null for RPC consumers. func protocolArtifactList(artifacts []map[string]any) []map[string]any { diff --git a/services/local-service/internal/orchestrator/task_entry_response.go b/services/local-service/internal/orchestrator/task_entry_response.go index d250e0d5b..68f9f9c05 100644 --- a/services/local-service/internal/orchestrator/task_entry_response.go +++ b/services/local-service/internal/orchestrator/task_entry_response.go @@ -19,15 +19,26 @@ func (s *Service) persistTaskPresentation(task runengine.TaskRecord, bubble map[ // buildTaskEntryResponse centralizes the protocol-facing result shape shared // by agent.input.submit and agent.task.start. Business branches should return -// task state and delivery objects, not hand-build response maps. -func buildTaskEntryResponse(task runengine.TaskRecord, bubble map[string]any, deliveryResult map[string]any) map[string]any { - response := map[string]any{ - "task": taskMap(task), - "bubble_message": bubble, - "delivery_result": nil, +// task state and delivery objects, not hand-build protocol maps first. +func buildTaskEntryResponse(task *runengine.TaskRecord, bubble map[string]any, deliveryResult map[string]any) (TaskEntryResponse, error) { + response := TaskEntryResponse{} + if task != nil { + taskDTO := taskDTOFromRecord(*task) + response.Task = &taskDTO + } + if len(bubble) > 0 { + bubbleDTO, err := bubbleMessageDTOFromMap(bubble) + if err != nil { + return TaskEntryResponse{}, err + } + response.BubbleMessage = &bubbleDTO } if len(deliveryResult) > 0 { - response["delivery_result"] = deliveryResult + deliveryDTO, err := deliveryResultDTOFromMap(deliveryResult) + if err != nil { + return TaskEntryResponse{}, err + } + response.DeliveryResult = &deliveryDTO } - return response + return response, nil } diff --git a/services/local-service/internal/orchestrator/task_query.go b/services/local-service/internal/orchestrator/task_query.go index 363b1ce3c..220aa3964 100644 --- a/services/local-service/internal/orchestrator/task_query.go +++ b/services/local-service/internal/orchestrator/task_query.go @@ -38,8 +38,18 @@ func (s *Service) TaskList(params map[string]any) (map[string]any, error) { // TaskDetailGet returns the task detail payload for `agent.task.detail.get`. // It keeps structured storage authoritative for formal evidence while allowing // live runtime state to fill task status fields that have not persisted yet. -func (s *Service) TaskDetailGet(params map[string]any) (map[string]any, error) { - taskID := stringValue(params, "task_id", "") +func (s *Service) TaskDetailGet(request TaskDetailGetRequest) (TaskDetailGetResponse, error) { + return s.taskDetailGet(request) +} + +// TaskDetailGetFromParams adapts the RPC-decoded request map once, then keeps +// task-detail assembly typed through the orchestrator response boundary. +func (s *Service) TaskDetailGetFromParams(params map[string]any) (TaskDetailGetResponse, error) { + return s.TaskDetailGet(TaskDetailGetRequestFromParams(params)) +} + +func (s *Service) taskDetailGet(request TaskDetailGetRequest) (TaskDetailGetResponse, error) { + taskID := request.TaskID task, ok := s.taskDetailFromStorage(taskID) if runtimeTask, runtimeOK := s.runEngine.TaskDetail(taskID); runtimeOK { if ok { @@ -50,7 +60,7 @@ func (s *Service) TaskDetailGet(params map[string]any) (map[string]any, error) { } } if !ok { - return nil, ErrTaskNotFound + return TaskDetailGetResponse{}, ErrTaskNotFound } securitySummary := cloneMap(task.SecuritySummary) @@ -84,7 +94,16 @@ func (s *Service) TaskDetailGet(params map[string]any) (map[string]any, error) { if approvalRequest != nil { securitySummary["pending_authorizations"] = 1 } + if strings.TrimSpace(stringValue(securitySummary, "risk_level", "")) == "" { + securitySummary["risk_level"] = firstNonEmptyString( + stringValue(approvalRequest, "risk_level", ""), + firstNonEmptyString(task.RiskLevel, s.risk.DefaultLevel()), + ) + } latestRestorePoint := s.normalizeTaskDetailRestorePoint(task.TaskID, securitySummary) + if strings.TrimSpace(stringValue(securitySummary, "security_status", "")) == "" { + securitySummary["security_status"] = deriveTaskDetailSecurityStatus(task, approvalRequest, authorizationRecord, auditRecord, latestRestorePoint) + } if latestRestorePoint == nil { securitySummary["latest_restore_point"] = nil } else { @@ -98,21 +117,274 @@ func (s *Service) TaskDetailGet(params map[string]any) (map[string]any, error) { deliveryResultValue = normalizedDelivery } - return map[string]any{ - "task": taskMap(task), - "timeline": protocolTaskStepList(timelineMap(task.Timeline)), - "delivery_result": deliveryResultValue, - "artifacts": protocolArtifactList(s.artifactsForTask(task, task.Artifacts)), - "citations": protocolCitationList(s.citationsForTask(task, task.Citations)), - "mirror_references": protocolMirrorReferenceList(task.MirrorReferences), - "approval_request": approvalRequestValue, - "authorization_record": authorizationRecordValue, - "audit_record": auditRecordValue, - "security_summary": securitySummary, - "runtime_summary": runtimeSummary, + deliveryResultDTO, err := deliveryResultDTOPointerFromValue(deliveryResultValue) + if err != nil { + return TaskDetailGetResponse{}, fmt.Errorf("delivery_result: %w", err) + } + artifacts, err := artifactDTOListFromValues(s.artifactsForTask(task, task.Artifacts)) + if err != nil { + return TaskDetailGetResponse{}, err + } + citations, err := citationDTOListFromValues(s.citationsForTask(task, task.Citations)) + if err != nil { + return TaskDetailGetResponse{}, err + } + mirrorReferences, err := mirrorReferenceDTOListFromValues(task.MirrorReferences) + if err != nil { + return TaskDetailGetResponse{}, err + } + approvalRequestDTO, err := approvalRequestDTOPointerFromValue(approvalRequestValue) + if err != nil { + return TaskDetailGetResponse{}, fmt.Errorf("approval_request: %w", err) + } + authorizationRecordDTO, err := authorizationRecordDTOPointerFromValue(authorizationRecordValue) + if err != nil { + return TaskDetailGetResponse{}, fmt.Errorf("authorization_record: %w", err) + } + auditRecordDTO, err := auditRecordDTOPointerFromValue(auditRecordValue) + if err != nil { + return TaskDetailGetResponse{}, fmt.Errorf("audit_record: %w", err) + } + securitySummaryDTO, err := securitySummaryDTOFromMap(securitySummary) + if err != nil { + return TaskDetailGetResponse{}, fmt.Errorf("security_summary: %w", err) + } + runtimeSummaryDTO, err := runtimeSummaryDTOFromMap(runtimeSummary) + if err != nil { + return TaskDetailGetResponse{}, fmt.Errorf("runtime_summary: %w", err) + } + + return TaskDetailGetResponse{ + Task: taskDTOFromRecord(task), + Timeline: taskStepDTOListFromRecords(task.Timeline), + DeliveryResult: deliveryResultDTO, + Artifacts: artifacts, + Citations: citations, + MirrorReferences: mirrorReferences, + ApprovalRequest: approvalRequestDTO, + AuthorizationRecord: authorizationRecordDTO, + AuditRecord: auditRecordDTO, + SecuritySummary: securitySummaryDTO, + RuntimeSummary: runtimeSummaryDTO, }, nil } +// deriveTaskDetailSecurityStatus rebuilds a missing task-detail status from the +// closest formal governance and recovery anchors instead of defaulting every +// incomplete record to the optimistic "normal" state. +func deriveTaskDetailSecurityStatus(task runengine.TaskRecord, approvalRequest, authorizationRecord, auditRecord, latestRestorePoint map[string]any) string { + if approvalRequest != nil || strings.TrimSpace(task.Status) == "waiting_auth" { + return "pending_confirmation" + } + if normalizeTaskDetailAuthorizationDecision(stringValue(authorizationRecord, "decision", "")) == "deny_once" { + return "intercepted" + } + // Preflight governance denials do not create an authorization record. When a + // historical task detail loses its stored security_summary, the denied audit + // anchor is the only formal signal that the task was intercepted by policy. + if strings.TrimSpace(stringValue(auditRecord, "action", "")) == "intercept_operation" && + strings.TrimSpace(stringValue(auditRecord, "result", "")) == "denied" { + return "intercepted" + } + if strings.TrimSpace(stringValue(auditRecord, "action", "")) == "restore_apply" { + switch strings.TrimSpace(stringValue(auditRecord, "result", "")) { + case "success": + return "recovered" + case "failed": + return "execution_error" + } + } + if strings.TrimSpace(task.Status) == "failed" { + return "execution_error" + } + if latestRestorePoint != nil { + return "recoverable" + } + return "normal" +} + +func taskDTOFromRecord(record runengine.TaskRecord) TaskDTO { + return TaskDTO{ + TaskID: record.TaskID, + SessionID: trimmedStringPointer(record.SessionID), + Title: record.Title, + SourceType: record.SourceType, + Status: record.Status, + Intent: intentPayloadFromTaskIntent(record.Intent), + CurrentStep: record.CurrentStep, + RiskLevel: record.RiskLevel, + LoopStopReason: trimmedStringPointer(record.LoopStopReason), + StartedAt: stringPointer(record.StartedAt.Format(dateTimeLayout)), + UpdatedAt: record.UpdatedAt.Format(dateTimeLayout), + FinishedAt: timePointerString(record.FinishedAt), + } +} + +func intentPayloadFromTaskIntent(intent map[string]any) *IntentPayload { + if len(intent) == 0 { + return nil + } + name := stringValue(intent, "name", "") + arguments := cloneMap(mapValue(intent, "arguments")) + if arguments == nil { + arguments = map[string]any{} + } + if strings.TrimSpace(name) == "" && len(arguments) == 0 { + return nil + } + return &IntentPayload{Name: name, Arguments: arguments} +} + +func taskStepDTOListFromRecords(timeline []runengine.TaskStepRecord) []TaskStepDTO { + if len(timeline) == 0 { + return []TaskStepDTO{} + } + result := make([]TaskStepDTO, 0, len(timeline)) + for _, step := range timeline { + result = append(result, TaskStepDTO{ + StepID: step.StepID, + TaskID: step.TaskID, + Name: step.Name, + Status: step.Status, + OrderIndex: step.OrderIndex, + InputSummary: step.InputSummary, + OutputSummary: step.OutputSummary, + }) + } + return result +} + +func deliveryResultDTOPointerFromValue(value any) (*DeliveryResultDTO, error) { + if value == nil { + return nil, nil + } + deliveryResult, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("must be object, got %T", value) + } + if len(deliveryResult) == 0 { + return nil, nil + } + dto, err := deliveryResultDTOFromMap(deliveryResult) + if err != nil { + return nil, err + } + return &dto, nil +} + +func artifactDTOListFromValues(artifacts []map[string]any) ([]ArtifactDTO, error) { + normalized := protocolArtifactList(artifacts) + result := make([]ArtifactDTO, 0, len(normalized)) + for index, artifact := range normalized { + dto, err := artifactDTOFromMap(artifact) + if err != nil { + return nil, fmt.Errorf("artifacts[%d]: %w", index, err) + } + result = append(result, dto) + } + return result, nil +} + +func citationDTOListFromValues(citations []map[string]any) ([]CitationDTO, error) { + normalized := protocolCitationList(citations) + result := make([]CitationDTO, 0, len(normalized)) + for index, citation := range normalized { + dto, err := citationDTOFromMap(citation) + if err != nil { + return nil, fmt.Errorf("citations[%d]: %w", index, err) + } + result = append(result, dto) + } + return result, nil +} + +func mirrorReferenceDTOListFromValues(references []map[string]any) ([]MirrorReferenceDTO, error) { + normalized := protocolMirrorReferenceList(references) + result := make([]MirrorReferenceDTO, 0, len(normalized)) + for index, reference := range normalized { + dto, err := mirrorReferenceDTOFromMap(reference) + if err != nil { + return nil, fmt.Errorf("mirror_references[%d]: %w", index, err) + } + result = append(result, dto) + } + return result, nil +} + +func approvalRequestDTOPointerFromValue(value any) (*ApprovalRequestDTO, error) { + if value == nil { + return nil, nil + } + approvalRequest, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("must be object, got %T", value) + } + if len(approvalRequest) == 0 { + return nil, nil + } + dto, err := approvalRequestDTOFromMap(approvalRequest) + if err != nil { + return nil, err + } + return &dto, nil +} + +func authorizationRecordDTOPointerFromValue(value any) (*AuthorizationRecordDTO, error) { + if value == nil { + return nil, nil + } + authorizationRecord, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("must be object, got %T", value) + } + if len(authorizationRecord) == 0 { + return nil, nil + } + dto, err := authorizationRecordDTOFromMap(authorizationRecord) + if err != nil { + return nil, err + } + return &dto, nil +} + +func auditRecordDTOPointerFromValue(value any) (*AuditRecordDTO, error) { + if value == nil { + return nil, nil + } + auditRecord, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("must be object, got %T", value) + } + if len(auditRecord) == 0 { + return nil, nil + } + dto, err := auditRecordDTOFromMap(auditRecord) + if err != nil { + return nil, err + } + return &dto, nil +} + +func trimmedStringPointer(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func stringPointer(value string) *string { + return &value +} + +func timePointerString(value *time.Time) *string { + if value == nil { + return nil + } + formatted := value.Format(dateTimeLayout) + return &formatted +} + // mergeRuntimeTaskDetail keeps first-class structured evidence authoritative but // lets the live runtime state win for task status fields when persistence is // temporarily stale. diff --git a/services/local-service/internal/orchestrator/task_query_storage.go b/services/local-service/internal/orchestrator/task_query_storage.go index 9befd0d33..f0b98e7ad 100644 --- a/services/local-service/internal/orchestrator/task_query_storage.go +++ b/services/local-service/internal/orchestrator/task_query_storage.go @@ -43,23 +43,6 @@ func taskIsTerminal(status string) bool { } } -// timelineMap converts internal timeline records into protocol-facing values. -func timelineMap(timeline []runengine.TaskStepRecord) []map[string]any { - result := make([]map[string]any, 0, len(timeline)) - for _, step := range timeline { - result = append(result, map[string]any{ - "step_id": step.StepID, - "task_id": step.TaskID, - "name": step.Name, - "status": step.Status, - "order_index": step.OrderIndex, - "input_summary": step.InputSummary, - "output_summary": step.OutputSummary, - }) - } - return result -} - // pageMap builds the shared paging payload used by list endpoints. func pageMap(limit, offset, total int) map[string]any { return map[string]any{ diff --git a/services/local-service/internal/orchestrator/task_start.go b/services/local-service/internal/orchestrator/task_start.go index 2cdeea078..f4a6131a5 100644 --- a/services/local-service/internal/orchestrator/task_start.go +++ b/services/local-service/internal/orchestrator/task_start.go @@ -13,7 +13,18 @@ import ( // StartTask creates the formal task/run mapping from an explicit object or an // inferred intent. Object-only starts stay in confirmation unless the caller // supplied enough instruction to enter governance and execution immediately. -func (s *Service) StartTask(params map[string]any) (map[string]any, error) { +func (s *Service) StartTask(request StartTaskRequest) (TaskEntryResponse, error) { + return s.StartTaskFromParams(request.ProtocolParamsMap()) +} + +// StartTaskFromParams lets the RPC layer hand the normalized protocol payload +// directly to the orchestrator so hot task-entry requests do not bounce through +// an extra typed-request-to-map conversion after boundary validation. +func (s *Service) StartTaskFromParams(params map[string]any) (TaskEntryResponse, error) { + return s.startTask(params) +} + +func (s *Service) startTask(params map[string]any) (TaskEntryResponse, error) { flow := s.prepareStartTaskFlow(params) if response, handled, err := s.maybeContinueStartTask(&flow); err != nil || handled { return response, err @@ -60,7 +71,7 @@ func (s *Service) prepareStartTaskFlow(params map[string]any) taskEntryFlow { } } -func (s *Service) maybeContinueStartTask(flow *taskEntryFlow) (map[string]any, bool, error) { +func (s *Service) maybeContinueStartTask(flow *taskEntryFlow) (TaskEntryResponse, bool, error) { response, handled, resolvedSessionID, err := s.maybeContinueExistingTask(flow.Params, flow.Snapshot, flow.ExplicitIntent, taskContinuationOptions{ ConfirmRequired: flow.ConfirmRequired, ForceConfirmRequired: flow.ForceConfirmRequired, @@ -71,10 +82,10 @@ func (s *Service) maybeContinueStartTask(flow *taskEntryFlow) (map[string]any, b if strings.TrimSpace(resolvedSessionID) != "" { flow.Params = withResolvedSessionID(flow.Params, resolvedSessionID) } - return nil, false, nil + return TaskEntryResponse{}, false, nil } -func (s *Service) maybeHandleExplicitScreenStart(flow taskEntryFlow) (map[string]any, bool, error) { +func (s *Service) maybeHandleExplicitScreenStart(flow taskEntryFlow) (TaskEntryResponse, bool, error) { return s.handleScreenAnalyzeStart(flow.Params, flow.Snapshot, flow.ExplicitIntent) } @@ -90,7 +101,7 @@ func (s *Service) suggestStartTaskIntent(flow taskEntryFlow) intent.Suggestion { return s.normalizeSuggestedIntentForAvailability(flow.Snapshot, suggestion, fallbackConfirmRequired) } -func (s *Service) maybeHandleSuggestedScreenStart(flow taskEntryFlow) (map[string]any, bool, error) { +func (s *Service) maybeHandleSuggestedScreenStart(flow taskEntryFlow) (TaskEntryResponse, bool, error) { return s.handleScreenAnalyzeSuggestion(flow.Params, flow.Snapshot, flow.Suggestion) } @@ -125,22 +136,22 @@ func (s *Service) createTaskFromEntryFlow(flow taskEntryFlow) runengine.TaskReco return task } -func (s *Service) finishStartTask(flow taskEntryFlow, task runengine.TaskRecord) (map[string]any, error) { +func (s *Service) finishStartTask(flow taskEntryFlow, task runengine.TaskRecord) (TaskEntryResponse, error) { bubble := s.delivery.BuildBubbleMessage(task.TaskID, bubbleTypeForSuggestion(flow.Suggestion.RequiresConfirm), bubbleTextForStart(flow.Suggestion), task.StartedAt.Format(dateTimeLayout)) if flow.Suggestion.RequiresConfirm { task = s.persistTaskPresentation(task, bubble) - return buildTaskEntryResponse(task, bubble, nil), nil + return buildTaskEntryResponse(&task, bubble, nil) } if queuedTask, queueBubble, queued, queueErr := s.queueTaskIfSessionBusy(task); queueErr != nil { - return nil, queueErr + return TaskEntryResponse{}, queueErr } else if queued { - return buildTaskEntryResponse(queuedTask, queueBubble, nil), nil + return buildTaskEntryResponse(&queuedTask, queueBubble, nil) } governedTask, governedResponse, handled, governanceErr := s.handleTaskGovernanceDecision(task, flow.Suggestion.Intent) if governanceErr != nil { - return nil, governanceErr + return TaskEntryResponse{}, governanceErr } if handled { return governedResponse, nil @@ -151,9 +162,9 @@ func (s *Service) finishStartTask(flow taskEntryFlow, task runengine.TaskRecord) var execErr error task, bubble, deliveryResult, _, execErr = s.executeTask(task, flow.Snapshot, flow.Suggestion.Intent) if execErr != nil { - return nil, execErr + return TaskEntryResponse{}, execErr } - return buildTaskEntryResponse(task, bubble, deliveryResult), nil + return buildTaskEntryResponse(&task, bubble, deliveryResult) } // taskStartConfirmRequired keeps confirmation as an explicit pre-execution gate. diff --git a/services/local-service/internal/orchestrator/utilities.go b/services/local-service/internal/orchestrator/utilities.go index 2fc890e61..4523d527a 100644 --- a/services/local-service/internal/orchestrator/utilities.go +++ b/services/local-service/internal/orchestrator/utilities.go @@ -113,13 +113,6 @@ func cloneMap(values map[string]any) map[string]any { return result } -func optionalFormalDeliveryResult(deliveryResult map[string]any) any { - if len(deliveryResult) == 0 { - return nil - } - return deliveryResult -} - // cloneMapSlice recursively copies a []map[string]any payload. func cloneMapSlice(values []map[string]any) []map[string]any { if len(values) == 0 { diff --git a/services/local-service/internal/perception/service.go b/services/local-service/internal/perception/service.go index 490febc57..5b1580be1 100644 --- a/services/local-service/internal/perception/service.go +++ b/services/local-service/internal/perception/service.go @@ -9,6 +9,7 @@ import ( "github.com/cialloclaw/cialloclaw/services/local-service/internal/runengine" "github.com/cialloclaw/cialloclaw/services/local-service/internal/textutil" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/urlutil" ) const ( @@ -66,7 +67,7 @@ func CaptureContextSignals(source, scene string, context map[string]any) SignalS Source: firstNonEmpty(strings.TrimSpace(source), stringValue(context, "source")), Scene: firstNonEmpty(strings.TrimSpace(scene), stringValue(context, "scene")), PageTitle: firstNonEmpty(stringValue(context, "page_title"), stringValue(page, "title")), - PageURL: firstNonEmpty(stringValue(context, "page_url"), stringValue(page, "url")), + PageURL: urlutil.SanitizeContextURL(firstNonEmpty(stringValue(context, "page_url"), stringValue(page, "url"))), AppName: firstNonEmpty(stringValue(context, "app_name"), stringValue(page, "app_name")), WindowTitle: firstNonEmpty(stringValue(context, "window_title"), stringValue(page, "window_title"), stringValue(screen, "window_title")), VisibleText: firstNonEmpty(stringValue(context, "visible_text"), stringValue(page, "visible_text"), stringValue(screen, "visible_text")), @@ -76,7 +77,7 @@ func CaptureContextSignals(source, scene string, context map[string]any) SignalS ClipboardMimeType: firstNonEmpty(stringValue(context, "clipboard_mime_type"), stringValue(clipboard, "mime_type")), HoverTarget: firstNonEmpty(stringValue(context, "hover_target"), stringValue(page, "hover_target"), stringValue(screen, "hover_target")), LastAction: firstNonEmpty(stringValue(context, "last_action"), stringValue(behavior, "last_action")), - ErrorText: firstNonEmpty(stringValue(context, "error_text"), stringValue(errorValue, "message")), + ErrorText: stringValue(errorValue, "message"), DwellMillis: intValue(context, "dwell_millis", intValue(behavior, "dwell_millis", 0)), WindowSwitchCount: intValue(context, "window_switch_count", intValue(behavior, "window_switch_count", 0)), PageSwitchCount: intValue(context, "page_switch_count", intValue(behavior, "page_switch_count", 0)), diff --git a/services/local-service/internal/perception/service_test.go b/services/local-service/internal/perception/service_test.go index da0f3e500..ce41da7c2 100644 --- a/services/local-service/internal/perception/service_test.go +++ b/services/local-service/internal/perception/service_test.go @@ -11,7 +11,7 @@ func TestCaptureContextSignalsNormalizesNestedSignals(t *testing.T) { snapshot := CaptureContextSignals("floating_ball", "hover", map[string]any{ "page": map[string]any{ "title": "Article", - "url": "https://example.com/article", + "url": "https://user:pass@example.com/article?draft=1#summary", "app_name": "browser", "window_title": "Browser - Example", "visible_text": "Important visible page text", @@ -31,6 +31,9 @@ func TestCaptureContextSignalsNormalizesNestedSignals(t *testing.T) { "page_switch_count": 2, "copy_count": 2, }, + "error": map[string]any{ + "message": " build failed ", + }, }) if snapshot.PageTitle != "Article" || snapshot.PageURL != "https://example.com/article" || snapshot.AppName != "browser" { @@ -45,6 +48,20 @@ func TestCaptureContextSignalsNormalizesNestedSignals(t *testing.T) { if snapshot.HoverTarget != "warning banner" { t.Fatalf("expected hover target to be normalized, got %+v", snapshot) } + if snapshot.ErrorText != "build failed" { + t.Fatalf("expected structured error message to be normalized, got %+v", snapshot) + } +} + +func TestCaptureContextSignalsDropsMalformedPageURLFromPersistedSignals(t *testing.T) { + snapshot := CaptureContextSignals("floating_ball", "hover", map[string]any{ + "page": map[string]any{ + "url": "https://user:pass@example.com/%zz?token=secret", + }, + }) + if snapshot.PageURL != "" { + t.Fatalf("expected malformed page url to be dropped, got %+v", snapshot) + } } func TestBehaviorSignalsAndOpportunitiesReflectPerceptionContext(t *testing.T) { diff --git a/services/local-service/internal/rpc/debug_http_test.go b/services/local-service/internal/rpc/debug_http_test.go index b01b68746..506e1409f 100644 --- a/services/local-service/internal/rpc/debug_http_test.go +++ b/services/local-service/internal/rpc/debug_http_test.go @@ -10,7 +10,7 @@ import ( // notifications can be fetched through the debug events endpoint. func TestHandleDebugEventsReturnsQueuedNotifications(t *testing.T) { server := newTestServer() - result, err := server.orchestrator.StartTask(map[string]any{ + result, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", diff --git a/services/local-service/internal/rpc/dispatch_core_test.go b/services/local-service/internal/rpc/dispatch_core_test.go index 867807f85..4db21339b 100644 --- a/services/local-service/internal/rpc/dispatch_core_test.go +++ b/services/local-service/internal/rpc/dispatch_core_test.go @@ -20,9 +20,10 @@ func TestDispatchTaskStartIgnoresUnsupportedIntentField(t *testing.T) { ID: json.RawMessage(`"req-task-start-ignore-intent"`), Method: methodAgentTaskStart, Params: mustMarshal(t, map[string]any{ - "session_id": "sess_ignore_intent", - "source": "floating_ball", - "trigger": "text_selected_click", + "request_meta": rpcRequestMeta("trace_task_start_ignore_intent"), + "session_id": "sess_ignore_intent", + "source": "floating_ball", + "trigger": "text_selected_click", "input": map[string]any{ "type": "text_selection", "text": "select this content", @@ -40,7 +41,7 @@ func TestDispatchTaskStartIgnoresUnsupportedIntentField(t *testing.T) { if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - task := success.Result.Data.(map[string]any)["task"].(map[string]any) + task := protocolMap(t, success.Result.Data)["task"].(map[string]any) if task["status"] != "confirming_intent" { t.Fatalf("expected task.start to stay in confirming_intent when intent is stripped, got %+v", task) } @@ -58,9 +59,10 @@ func TestDispatchTaskStartFileInstructionSkipsIntentConfirmation(t *testing.T) { ID: json.RawMessage(`"req-task-start-file-instruction"`), Method: methodAgentTaskStart, Params: mustMarshal(t, map[string]any{ - "session_id": "sess_file_instruction_rpc", - "source": "floating_ball", - "trigger": "file_drop", + "request_meta": rpcRequestMeta("trace_task_start_file_instruction"), + "session_id": "sess_file_instruction_rpc", + "source": "floating_ball", + "trigger": "file_drop", "input": map[string]any{ "type": "file", "text": "帮我看看这里面有什么", @@ -80,7 +82,7 @@ func TestDispatchTaskStartFileInstructionSkipsIntentConfirmation(t *testing.T) { if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - result := success.Result.Data.(map[string]any) + result := protocolMap(t, success.Result.Data) task := result["task"].(map[string]any) if task["status"] == "confirming_intent" || task["current_step"] == "intent_confirmation" { t.Fatalf("expected instructed file start to skip intent confirmation, got %+v", task) @@ -100,7 +102,7 @@ func TestDispatchTaskStartFileInstructionSkipsIntentConfirmation(t *testing.T) { func TestDispatchTaskDetailGetIncludesActiveApprovalAnchor(t *testing.T) { server := newTestServer() - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_detail_rpc", "source": "floating_ball", "trigger": "hover_text_input", @@ -124,14 +126,17 @@ func TestDispatchTaskDetailGetIncludesActiveApprovalAnchor(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-task-detail-anchor"`), Method: methodAgentTaskDetailGet, - Params: mustMarshal(t, map[string]any{"task_id": taskID}), + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_detail_anchor"), + "task_id": taskID, + }), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) + data := protocolMap(t, success.Result.Data) approvalRequest, ok := data["approval_request"].(map[string]any) if !ok || approvalRequest["task_id"] != taskID { t.Fatalf("expected approval_request task_id %s, got %+v", taskID, data["approval_request"]) @@ -147,7 +152,7 @@ func TestDispatchTaskDetailGetIncludesActiveApprovalAnchor(t *testing.T) { func TestDispatchTaskDetailGetOmitsApprovalAnchorForCompletedTask(t *testing.T) { server := newTestServer() - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_detail_rpc_done", "source": "floating_ball", "trigger": "hover_text_input", @@ -174,14 +179,17 @@ func TestDispatchTaskDetailGetOmitsApprovalAnchorForCompletedTask(t *testing.T) JSONRPC: "2.0", ID: json.RawMessage(`"req-task-detail-no-anchor"`), Method: methodAgentTaskDetailGet, - Params: mustMarshal(t, map[string]any{"task_id": taskID}), + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_detail_no_anchor"), + "task_id": taskID, + }), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) + data := protocolMap(t, success.Result.Data) if data["approval_request"] != nil { t.Fatalf("expected approval_request to be nil, got %+v", data["approval_request"]) } @@ -197,7 +205,7 @@ func TestDispatchTaskDetailGetOmitsApprovalAnchorForCompletedTask(t *testing.T) func TestDispatchTaskControlValidationAndStatusErrors(t *testing.T) { server := newTestServer() - completedTask, err := server.orchestrator.StartTask(map[string]any{ + completedTask, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -220,7 +228,7 @@ func TestDispatchTaskControlValidationAndStatusErrors(t *testing.T) { t.Fatal("expected runtime task completion to succeed") } - waitingTask, err := server.orchestrator.StartTask(map[string]any{ + waitingTask, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_waiting_task", "source": "floating_ball", "trigger": "text_selected_click", @@ -240,10 +248,10 @@ func TestDispatchTaskControlValidationAndStatusErrors(t *testing.T) { expectedCode int expectedError string }{ - {name: "missing task id", params: map[string]any{"action": "pause"}, expectedCode: errInvalidParams, expectedError: "INVALID_PARAMS"}, - {name: "unsupported action", params: map[string]any{"task_id": completedTaskID, "action": "skip"}, expectedCode: errInvalidParams, expectedError: "INVALID_PARAMS"}, - {name: "finished task", params: map[string]any{"task_id": completedTaskID, "action": "cancel"}, expectedCode: 1001005, expectedError: "TASK_ALREADY_FINISHED"}, - {name: "status invalid", params: map[string]any{"task_id": waitingTaskID, "action": "pause"}, expectedCode: 1001004, expectedError: "TASK_STATUS_INVALID"}, + {name: "missing task id", params: map[string]any{"request_meta": rpcRequestMeta("trace_task_control_missing_task"), "action": "pause"}, expectedCode: errInvalidParams, expectedError: "INVALID_PARAMS"}, + {name: "unsupported action", params: map[string]any{"request_meta": rpcRequestMeta("trace_task_control_unsupported"), "task_id": completedTaskID, "action": "skip"}, expectedCode: errInvalidParams, expectedError: "INVALID_PARAMS"}, + {name: "finished task", params: map[string]any{"request_meta": rpcRequestMeta("trace_task_control_finished"), "task_id": completedTaskID, "action": "cancel"}, expectedCode: 1001005, expectedError: "TASK_ALREADY_FINISHED"}, + {name: "status invalid", params: map[string]any{"request_meta": rpcRequestMeta("trace_task_control_status_invalid"), "task_id": waitingTaskID, "action": "pause"}, expectedCode: 1001004, expectedError: "TASK_STATUS_INVALID"}, } for _, test := range tests { @@ -291,7 +299,7 @@ func TestDispatchTaskConfirmRejectsMalformedCorrectionText(t *testing.T) { func TestDispatchTaskListClampsPagingParams(t *testing.T) { server := newTestServer() for index := 0; index < 25; index++ { - _, err := server.orchestrator.StartTask(map[string]any{ + _, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": fmt.Sprintf("sess_rpc_task_list_%02d", index), "source": "floating_ball", "trigger": "hover_text_input", @@ -316,11 +324,12 @@ func TestDispatchTaskListClampsPagingParams(t *testing.T) { ID: json.RawMessage(`"req-task-list-clamp"`), Method: methodAgentTaskList, Params: mustMarshal(t, map[string]any{ - "group": "unfinished", - "limit": 0, - "offset": -5, - "sort_by": "updated_at", - "sort_order": "desc", + "request_meta": rpcRequestMeta("trace_task_list_clamp"), + "group": "unfinished", + "limit": 0, + "offset": -5, + "sort_by": "updated_at", + "sort_order": "desc", }), }) @@ -328,8 +337,8 @@ func TestDispatchTaskListClampsPagingParams(t *testing.T) { if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) - items := data["items"].([]map[string]any) + data := protocolMap(t, success.Result.Data) + items := protocolMapSlice(t, data["items"]) if len(items) != 20 { t.Fatalf("expected rpc task.list to clamp zero limit to 20 items, got %d", len(items)) } @@ -361,11 +370,12 @@ func TestDispatchTaskEventsListReturnsLoopEvents(t *testing.T) { ID: json.RawMessage(`"req-task-events-list"`), Method: methodAgentTaskEventsList, Params: mustMarshal(t, map[string]any{ - "task_id": "task_rpc_loop_001", - "run_id": "run_rpc_loop_001", - "type": "loop.completed", - "limit": 20, - "offset": 0, + "request_meta": rpcRequestMeta("trace_task_events_list"), + "task_id": "task_rpc_loop_001", + "run_id": "run_rpc_loop_001", + "type": "loop.completed", + "limit": 20, + "offset": 0, }), }) @@ -373,7 +383,7 @@ func TestDispatchTaskEventsListReturnsLoopEvents(t *testing.T) { if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - items := success.Result.Data.(map[string]any)["items"].([]map[string]any) + items := protocolMapSlice(t, protocolMap(t, success.Result.Data)["items"]) if len(items) != 1 || items[0]["type"] != "loop.completed" { t.Fatalf("expected rpc task events list to return loop.completed, got %+v", items) } @@ -400,14 +410,19 @@ func TestDispatchTaskToolCallsListReturnsPersistedToolCalls(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-task-tool-calls-list"`), Method: methodAgentTaskToolCallsList, - Params: mustMarshal(t, map[string]any{"task_id": "task_rpc_tool_001", "limit": 20, "offset": 0}), + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_tool_calls_list"), + "task_id": "task_rpc_tool_001", + "limit": 20, + "offset": 0, + }), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - items := success.Result.Data.(map[string]any)["items"].([]map[string]any) + items := protocolMapSlice(t, protocolMap(t, success.Result.Data)["items"]) if len(items) != 1 || items[0]["tool_name"] != "read_file" { t.Fatalf("expected rpc task tool calls list to return read_file, got %+v", items) } @@ -415,7 +430,7 @@ func TestDispatchTaskToolCallsListReturnsPersistedToolCalls(t *testing.T) { func TestDispatchTaskSteerReturnsUpdatedTask(t *testing.T) { server := newTestServer() - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_rpc_task_steer", "source": "floating_ball", "trigger": "hover_text_input", @@ -438,15 +453,20 @@ func TestDispatchTaskSteerReturnsUpdatedTask(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-task-steer"`), Method: methodAgentTaskSteer, - Params: mustMarshal(t, map[string]any{"task_id": taskID, "message": "Also include a short summary section."}), + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_steer"), + "task_id": taskID, + "message": "Also include a short summary section.", + }), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - if success.Result.Data.(map[string]any)["task"].(map[string]any)["task_id"] != taskID { - t.Fatalf("expected rpc task steer to keep task id, got %+v", success.Result.Data) + successData := protocolMap(t, success.Result.Data) + if successData["task"].(map[string]any)["task_id"] != taskID { + t.Fatalf("expected rpc task steer to keep task id, got %+v", successData) } } @@ -458,13 +478,13 @@ func TestDispatchReturnsSettingsGet(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-settings-get"`), Method: methodAgentSettingsGet, - Params: mustMarshal(t, map[string]any{"scope": "all"}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_settings_get"), "scope": "all"}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - credentials := success.Result.Data.(map[string]any)["settings"].(map[string]any)["models"].(map[string]any)["credentials"].(map[string]any) + credentials := protocolMap(t, success.Result.Data)["settings"].(map[string]any)["models"].(map[string]any)["credentials"].(map[string]any) if _, ok := credentials["stronghold"].(map[string]any); !ok { t.Fatalf("expected settings get to include stronghold status, got %+v", credentials) } @@ -479,6 +499,7 @@ func TestDispatchReturnsSettingsUpdate(t *testing.T) { ID: json.RawMessage(`"req-settings-update"`), Method: methodAgentSettingsUpdate, Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_settings_update"), "models": map[string]any{ "provider": "openai", "api_key": "rpc-secret-key", @@ -489,15 +510,16 @@ func TestDispatchReturnsSettingsUpdate(t *testing.T) { if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - models := success.Result.Data.(map[string]any)["effective_settings"].(map[string]any)["models"].(map[string]any) + successData := protocolMap(t, success.Result.Data) + models := successData["effective_settings"].(map[string]any)["models"].(map[string]any) if models["provider_api_key_configured"] != true { t.Fatalf("expected settings update to mark provider key configured, got %+v", models) } if _, exists := models["api_key"]; exists { t.Fatalf("expected settings update response to keep api_key redacted, got %+v", models) } - if success.Result.Data.(map[string]any)["apply_mode"] != "next_task_effective" || success.Result.Data.(map[string]any)["need_restart"] != false { - t.Fatalf("expected model settings update to be next_task_effective, got %+v", success.Result.Data) + if successData["apply_mode"] != "next_task_effective" || successData["need_restart"] != false { + t.Fatalf("expected model settings update to be next_task_effective, got %+v", successData) } } @@ -508,6 +530,7 @@ func TestDispatchReturnsSettingsModelValidate(t *testing.T) { ID: json.RawMessage(`"req-settings-model-validate"`), Method: methodAgentSettingsModelValidate, Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_settings_model_validate"), "models": map[string]any{ "provider": "openai", "base_url": "https://api.example.com/v1", @@ -519,7 +542,7 @@ func TestDispatchReturnsSettingsModelValidate(t *testing.T) { if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) + data := protocolMap(t, success.Result.Data) if data["ok"] != false || data["status"] != "missing_api_key" { t.Fatalf("expected structured validation failure result, got %+v", data) } @@ -531,14 +554,14 @@ func TestDispatchReturnsPluginRuntimeList(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-plugin-runtime-list"`), Method: methodAgentPluginRuntimeList, - Params: mustMarshal(t, map[string]any{}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_plugin_runtime_list")}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) - items := data["items"].([]map[string]any) + data := protocolMap(t, success.Result.Data) + items := protocolMapSlice(t, data["items"]) if len(items) == 0 { t.Fatalf("expected plugin runtime query to return declared runtimes, got %+v", data) } @@ -546,7 +569,7 @@ func TestDispatchReturnsPluginRuntimeList(t *testing.T) { if !ok || manifest["plugin_id"] == nil || manifest["source"] == nil { t.Fatalf("expected plugin runtime items to include formal manifest linkage, got %+v", items[0]) } - metrics := data["metrics"].([]map[string]any) + metrics := protocolMapSlice(t, data["metrics"]) if len(metrics) == 0 { t.Fatalf("expected plugin runtime query to return metric snapshots, got %+v", data) } @@ -558,15 +581,16 @@ func TestDispatchReturnsPluginList(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-plugin-list"`), Method: methodAgentPluginList, - Params: mustMarshal(t, map[string]any{"query": "ocr", "page": map[string]any{"limit": 10, "offset": 0}}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_plugin_list"), "query": "ocr", "page": map[string]any{"limit": 10, "offset": 0}}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - items := success.Result.Data.(map[string]any)["items"].([]map[string]any) + successData := protocolMap(t, success.Result.Data) + items := protocolMapSlice(t, successData["items"]) if len(items) != 1 || items[0]["plugin_id"] != "ocr" { - t.Fatalf("expected plugin list query to return filtered ocr plugin, got %+v", success.Result.Data) + t.Fatalf("expected plugin list query to return filtered ocr plugin, got %+v", successData) } } @@ -576,13 +600,13 @@ func TestDispatchReturnsPluginDetail(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-plugin-detail"`), Method: methodAgentPluginDetailGet, - Params: mustMarshal(t, map[string]any{"plugin_id": "ocr"}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_plugin_detail_get"), "plugin_id": "ocr"}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) + data := protocolMap(t, success.Result.Data) if data["plugin"].(map[string]any)["plugin_id"] != "ocr" { t.Fatalf("expected plugin detail query to resolve ocr plugin, got %+v", data) } @@ -590,7 +614,7 @@ func TestDispatchReturnsPluginDetail(t *testing.T) { if !ok { t.Fatalf("expected ocr worker runtime to exist") } - toolItems := data["tools"].([]map[string]any) + toolItems := protocolMapSlice(t, data["tools"]) if len(toolItems) != len(runtime.Capabilities) { t.Fatalf("expected one contract per declared capability, got %+v", data) } @@ -606,3 +630,47 @@ func TestDispatchReturnsPluginDetail(t *testing.T) { } } } + +func TestDispatchRejectsPluginQueriesMissingRequestMeta(t *testing.T) { + server := newTestServer() + testCases := []struct { + name string + method string + params map[string]any + }{ + { + name: "plugin runtime list", + method: methodAgentPluginRuntimeList, + params: map[string]any{}, + }, + { + name: "plugin list", + method: methodAgentPluginList, + params: map[string]any{"query": "ocr"}, + }, + { + name: "plugin detail get", + method: methodAgentPluginDetailGet, + params: map[string]any{"plugin_id": "ocr"}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-plugin-invalid"`), + Method: testCase.method, + Params: mustMarshal(t, testCase.params), + }) + + rpcErr, ok := response.(errorEnvelope) + if !ok { + t.Fatalf("expected error response envelope, got %#v", response) + } + if rpcErr.Error.Code != errInvalidParams || rpcErr.Error.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params response, got %#v", rpcErr) + } + }) + } +} diff --git a/services/local-service/internal/rpc/dispatch_storage_test.go b/services/local-service/internal/rpc/dispatch_storage_test.go index d94a09dd0..b8d91715b 100644 --- a/services/local-service/internal/rpc/dispatch_storage_test.go +++ b/services/local-service/internal/rpc/dispatch_storage_test.go @@ -38,14 +38,14 @@ func TestDispatchReturnsSecurityAuditList(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-security-audit-list"`), Method: methodAgentSecurityAuditList, - Params: mustMarshal(t, map[string]any{"task_id": "task_001", "limit": 20, "offset": 0}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_security_audit_list"), "task_id": "task_001", "limit": 20, "offset": 0}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - items := success.Result.Data.(map[string]any)["items"].([]map[string]any) + items := protocolMapSlice(t, protocolMap(t, success.Result.Data)["items"]) if len(items) != 1 || items[0]["audit_id"] != "audit_001" { t.Fatalf("expected stored audit_001, got %+v", items) } @@ -70,14 +70,14 @@ func TestDispatchReturnsSecurityRestorePointsList(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-security-restore-points-list"`), Method: methodAgentSecurityRestorePointsList, - Params: mustMarshal(t, map[string]any{"task_id": "task_001", "limit": 20, "offset": 0}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_security_restore_points_list"), "task_id": "task_001", "limit": 20, "offset": 0}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - items := success.Result.Data.(map[string]any)["items"].([]map[string]any) + items := protocolMapSlice(t, protocolMap(t, success.Result.Data)["items"]) if len(items) != 1 || items[0]["recovery_point_id"] != "rp_001" { t.Fatalf("expected stored rp_001, got %+v", items) } @@ -87,7 +87,7 @@ func TestDispatchReturnsSecurityRestoreApplyResult(t *testing.T) { storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "restore-apply.db"))) defer func() { _ = storageService.Close() }() server := newTestServerWithStorage(storageService) - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_restore", "source": "floating_ball", "trigger": "text_selected_click", @@ -115,14 +115,14 @@ func TestDispatchReturnsSecurityRestoreApplyResult(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-security-restore-apply"`), Method: methodAgentSecurityRestoreApply, - Params: mustMarshal(t, map[string]any{"task_id": taskID, "recovery_point_id": "rp_001"}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_security_restore_apply"), "task_id": taskID, "recovery_point_id": "rp_001"}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) + data := protocolMap(t, success.Result.Data) if _, ok := data["applied"].(bool); !ok || data["task"].(map[string]any)["status"] != "waiting_auth" || data["recovery_point"].(map[string]any)["recovery_point_id"] != "rp_001" { t.Fatalf("unexpected restore apply result %+v", data) } @@ -134,18 +134,18 @@ func TestDispatchReturnsNotepadUpdateResult(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-notepad-update"`), Method: methodAgentNotepadUpdate, - Params: mustMarshal(t, map[string]any{"item_id": "todo_002", "action": "move_upcoming"}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_notepad_update"), "item_id": "todo_002", "action": "move_upcoming"}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) + data := protocolMap(t, success.Result.Data) notepadItem, ok := data["notepad_item"].(map[string]any) if !ok || notepadItem["bucket"] != "upcoming" { t.Fatalf("expected updated notepad item bucket upcoming, got %+v", data) } - refreshGroups := data["refresh_groups"].([]string) + refreshGroups := stringSliceValue(data["refresh_groups"]) if len(refreshGroups) != 2 || refreshGroups[0] != "later" || refreshGroups[1] != "upcoming" { t.Fatalf("expected refresh_groups to include source and target buckets, got %+v", refreshGroups) } @@ -173,13 +173,13 @@ func TestDispatchReturnsTaskArtifactList(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-task-artifact-list"`), Method: methodAgentTaskArtifactList, - Params: mustMarshal(t, map[string]any{"task_id": "task_rpc_001", "limit": 20, "offset": 0}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_task_artifact_list"), "task_id": "task_rpc_001", "limit": 20, "offset": 0}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - items := success.Result.Data.(map[string]any)["items"].([]map[string]any) + items := protocolMapSlice(t, protocolMap(t, success.Result.Data)["items"]) if len(items) != 1 || items[0]["artifact_id"] != "art_rpc_001" { t.Fatalf("expected artifact list item, got %+v", items) } @@ -207,13 +207,13 @@ func TestDispatchReturnsTaskArtifactOpen(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-task-artifact-open"`), Method: methodAgentTaskArtifactOpen, - Params: mustMarshal(t, map[string]any{"task_id": "task_rpc_open_001", "artifact_id": "art_rpc_open_001"}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_task_artifact_open"), "task_id": "task_rpc_open_001", "artifact_id": "art_rpc_open_001"}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) + data := protocolMap(t, success.Result.Data) if data["open_action"] != "open_file" || data["artifact"].(map[string]any)["artifact_id"] != "art_rpc_open_001" { t.Fatalf("expected opened artifact, got %+v", data) } @@ -241,20 +241,21 @@ func TestDispatchReturnsDeliveryOpenForArtifact(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-delivery-open-artifact"`), Method: methodAgentDeliveryOpen, - Params: mustMarshal(t, map[string]any{"task_id": "task_delivery_rpc_001", "artifact_id": "art_delivery_rpc_001"}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_delivery_open_artifact"), "task_id": "task_delivery_rpc_001", "artifact_id": "art_delivery_rpc_001"}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - if success.Result.Data.(map[string]any)["open_action"] != "open_file" { - t.Fatalf("expected open_file action, got %+v", success.Result.Data) + data := protocolMap(t, success.Result.Data) + if data["open_action"] != "open_file" { + t.Fatalf("expected open_file action, got %+v", data) } } func TestDispatchReturnsDeliveryOpenForTaskResult(t *testing.T) { server := newTestServer() - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_delivery_rpc", "source": "floating_ball", "trigger": "hover_text_input", @@ -277,14 +278,15 @@ func TestDispatchReturnsDeliveryOpenForTaskResult(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-delivery-open-task"`), Method: methodAgentDeliveryOpen, - Params: mustMarshal(t, map[string]any{"task_id": taskID}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_delivery_open_task"), "task_id": taskID}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - if success.Result.Data.(map[string]any)["open_action"] != "task_detail" { - t.Fatalf("expected task_detail action, got %+v", success.Result.Data) + data := protocolMap(t, success.Result.Data) + if data["open_action"] != "task_detail" { + t.Fatalf("expected task_detail action, got %+v", data) } } @@ -333,13 +335,13 @@ func TestDispatchReturnsDeliveryOpenForResultPage(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-delivery-open-result-page"`), Method: methodAgentDeliveryOpen, - Params: mustMarshal(t, map[string]any{"task_id": taskID}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta("trace_delivery_open_result_page"), "task_id": taskID}), }) success, ok := response.(successEnvelope) if !ok { t.Fatalf("expected success response envelope, got %#v", response) } - data := success.Result.Data.(map[string]any) + data := protocolMap(t, success.Result.Data) if data["open_action"] != "result_page" { t.Fatalf("expected result_page action, got %+v", data) } @@ -378,7 +380,7 @@ func TestDispatchReturnsFormalTaskInspectorRunSourceErrors(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(fmt.Sprintf(`"%s"`, test.requestID)), Method: methodAgentTaskInspectorRun, - Params: mustMarshal(t, map[string]any{"target_sources": test.targetSources}), + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta(test.requestID), "target_sources": test.targetSources}), }) errEnvelope, ok := response.(errorEnvelope) if !ok { diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index c98b735fe..647fae85c 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -3,206 +3,685 @@ package rpc import ( "bytes" "encoding/json" + "math" + "strings" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" ) -// RequestMeta mirrors packages/protocol RequestMeta and carries the trace -// anchor that error envelopes return to clients. -type RequestMeta struct { - TraceID string `json:"trace_id,omitempty"` - ClientTime string `json:"client_time,omitempty"` -} - -type pageContext struct { - Title string `json:"title,omitempty"` - AppName string `json:"app_name,omitempty"` - URL string `json:"url,omitempty"` - BrowserKind string `json:"browser_kind,omitempty"` - ProcessPath string `json:"process_path,omitempty"` - ProcessID int `json:"process_id,omitempty"` - WindowTitle string `json:"window_title,omitempty"` - VisibleText string `json:"visible_text,omitempty"` - HoverTarget string `json:"hover_target,omitempty"` +var ( + requestSourceSet = map[string]struct{}{ + "floating_ball": {}, + "dashboard": {}, + "tray_panel": {}, + } + inputSubmitTriggerSet = map[string]struct{}{ + "voice_commit": {}, + "hover_text_input": {}, + } + requestTriggerSet = map[string]struct{}{ + "voice_commit": {}, + "hover_text_input": {}, + "text_selected_click": {}, + "file_drop": {}, + "error_detected": {}, + "recommendation_click": {}, + } + inputModeSet = map[string]struct{}{ + "voice": {}, + "text": {}, + } + inputTypeSet = map[string]struct{}{ + "text": {}, + "text_selection": {}, + "file": {}, + "error": {}, + } + taskListGroupSet = map[string]struct{}{ + "unfinished": {}, + "finished": {}, + } + taskListSortBySet = map[string]struct{}{ + "updated_at": {}, + "started_at": {}, + "finished_at": {}, + } + taskListSortOrderSet = map[string]struct{}{ + "asc": {}, + "desc": {}, + } + deliveryTypeSet = map[string]struct{}{ + "bubble": {}, + "workspace_document": {}, + "result_page": {}, + "open_file": {}, + "reveal_in_folder": {}, + "task_detail": {}, + } + browserKindSet = map[string]struct{}{ + "chrome": {}, + "edge": {}, + "other_browser": {}, + "non_browser": {}, + } +) + +// AgentInputSubmitParams reuses the orchestrator request DTO so the stable +// contract only needs to be maintained in one Go package. +type AgentInputSubmitParams = orchestrator.SubmitInputRequest + +// AgentTaskStartParams reuses the orchestrator request DTO. Its json:"-" +// Intent field keeps unsupported client intent input out of the stable RPC +// contract while preserving the orchestrator's internal testing hook. +type AgentTaskStartParams = orchestrator.StartTaskRequest + +// AgentTaskDetailGetParams reuses the orchestrator request DTO so stable task +// detail lookups validate the same typed contract the orchestrator consumes. +type AgentTaskDetailGetParams = orchestrator.TaskDetailGetRequest + +type intentPayload struct { + Name string `json:"name,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` } -type screenContext struct { - Summary string `json:"summary,omitempty"` - ScreenSummary string `json:"screen_summary,omitempty"` - VisibleText string `json:"visible_text,omitempty"` - WindowTitle string `json:"window_title,omitempty"` - HoverTarget string `json:"hover_target,omitempty"` +// AgentTaskConfirmParams mirrors packages/protocol AgentTaskConfirmParams and +// keeps correction fields typed at the RPC boundary so malformed payloads fail +// before confirm-flow business logic tries to reinterpret them. +type AgentTaskConfirmParams struct { + RequestMeta orchestrator.RequestMeta `json:"request_meta"` + TaskID string `json:"task_id,omitempty"` + Confirmed bool `json:"confirmed,omitempty"` + CorrectedIntent *intentPayload `json:"corrected_intent,omitempty"` + CorrectionText *string `json:"correction_text,omitempty"` } - -type behaviorContext struct { - LastAction string `json:"last_action,omitempty"` - DwellMillis int `json:"dwell_millis,omitempty"` - CopyCount int `json:"copy_count,omitempty"` - WindowSwitchCount int `json:"window_switch_count,omitempty"` - PageSwitchCount int `json:"page_switch_count,omitempty"` + +func decodeAgentInputSubmitParams(raw json.RawMessage) (map[string]any, *rpcError) { + return decodeTypedProtocolParams(raw, validateAgentInputSubmitParams, orchestrator.SubmitInputRequestFromParams, func(request AgentInputSubmitParams) map[string]any { + return request.ProtocolParamsMap() + }) } -type selectionContext struct { - Text string `json:"text,omitempty"` +func decodeAgentTaskStartParams(raw json.RawMessage) (map[string]any, *rpcError) { + return decodeTypedProtocolParams(raw, validateAgentTaskStartParams, func(params map[string]any) AgentTaskStartParams { + request := orchestrator.StartTaskRequestFromParams(params) + request.Intent = nil + return request + }, func(request AgentTaskStartParams) map[string]any { + return request.ProtocolParamsMap() + }) } -type errorContext struct { - Message string `json:"message,omitempty"` +func decodeAgentTaskDetailGetParams(raw json.RawMessage) (map[string]any, *rpcError) { + return decodeTypedProtocolParams(raw, validateAgentTaskDetailGetParams, orchestrator.TaskDetailGetRequestFromParams, func(request AgentTaskDetailGetParams) map[string]any { + return request.ProtocolParamsMap() + }) } -type clipboardContext struct { - Text string `json:"text,omitempty"` +func decodeAgentTaskConfirmParams(raw json.RawMessage) (map[string]any, *rpcError) { + return decodeParamsWithValidation(raw, validateAgentTaskConfirmParams) } -// inputContext mirrors the stable InputContext envelope shared by -// agent.input.submit and agent.task.start. It stays typed at the RPC boundary, -// then converts to the orchestrator's normalized context capture payload. -type inputContext struct { - Page *pageContext `json:"page,omitempty"` - Screen *screenContext `json:"screen,omitempty"` - Behavior *behaviorContext `json:"behavior,omitempty"` - Selection *selectionContext `json:"selection,omitempty"` - Error *errorContext `json:"error,omitempty"` - Clipboard *clipboardContext `json:"clipboard,omitempty"` - Text string `json:"text,omitempty"` - SelectionText string `json:"selection_text,omitempty"` - Files []string `json:"files,omitempty"` - FilePaths []string `json:"file_paths,omitempty"` - ScreenSummary string `json:"screen_summary,omitempty"` - ClipboardText string `json:"clipboard_text,omitempty"` - HoverTarget string `json:"hover_target,omitempty"` - LastAction string `json:"last_action,omitempty"` - DwellMillis int `json:"dwell_millis,omitempty"` - CopyCount int `json:"copy_count,omitempty"` - WindowSwitchCount int `json:"window_switch_count,omitempty"` - PageSwitchCount int `json:"page_switch_count,omitempty"` +func decodeAgentTaskListParams(raw json.RawMessage) (map[string]any, *rpcError) { + return decodeParamsWithValidation(raw, validateAgentTaskListParams) } -type agentInputSubmitInput struct { - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` - InputMode string `json:"input_mode,omitempty"` +func decodeTypedProtocolParams[T any](raw json.RawMessage, validate func(map[string]any) *rpcError, fromParams func(map[string]any) T, normalize func(T) map[string]any) (map[string]any, *rpcError) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + trimmed = []byte("{}") + } + var payload map[string]any + if err := json.Unmarshal(trimmed, &payload); err != nil { + return nil, &rpcError{ + Code: errInvalidParams, + Message: "INVALID_PARAMS", + Detail: "params do not match the registered method dto", + TraceID: "trace_rpc_params", + } + } + if validate != nil { + if err := validate(payload); err != nil { + return nil, err + } + } + if fromParams != nil && normalize != nil { + typedPayload := fromParams(payload) + return normalize(typedPayload), nil + } + return map[string]any{}, nil } -type voiceMeta struct { - VoiceSessionID string `json:"voice_session_id,omitempty"` - IsLockedSession bool `json:"is_locked_session,omitempty"` - ASRConfidence float64 `json:"asr_confidence,omitempty"` - SegmentID string `json:"segment_id,omitempty"` +func decodeParamsRequiringRequestMeta(raw json.RawMessage) (map[string]any, *rpcError) { + return decodeParamsWithValidation(raw, requireRequestMeta) } -type agentInputSubmitOptions struct { - ConfirmRequired bool `json:"confirm_required,omitempty"` - PreferredDelivery string `json:"preferred_delivery,omitempty"` +func decodeParamsWithValidation(raw json.RawMessage, validate func(map[string]any) *rpcError) (map[string]any, *rpcError) { + params, err := decodeParams(raw) + if err != nil { + return nil, err + } + if validate != nil { + if err := validate(params); err != nil { + return nil, err + } + } + return params, nil } -// AgentInputSubmitParams mirrors packages/protocol AgentInputSubmitParams for -// the Go RPC boundary. -type AgentInputSubmitParams struct { - RequestMeta RequestMeta `json:"request_meta"` - SessionID string `json:"session_id,omitempty"` - Source string `json:"source,omitempty"` - Trigger string `json:"trigger,omitempty"` - Input agentInputSubmitInput `json:"input"` - Context *inputContext `json:"context,omitempty"` - VoiceMeta *voiceMeta `json:"voice_meta,omitempty"` - Options *agentInputSubmitOptions `json:"options,omitempty"` -} - -type agentTaskStartInput struct { - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` - Files []string `json:"files,omitempty"` - PageContext *pageContext `json:"page_context,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` -} - -type deliveryPreference struct { - Preferred string `json:"preferred,omitempty"` - Fallback string `json:"fallback,omitempty"` +func validateAgentInputSubmitParams(params map[string]any) *rpcError { + if err := requireRequestMeta(params); err != nil { + return err + } + if err := optionalStringField(params, "session_id"); err != nil { + return err + } + input, err := requireObject(params, "input") + if err != nil { + return err + } + if _, err := requireObject(params, "context"); err != nil { + return err + } + if err := requireEnumValue(params, "source", requestSourceSet); err != nil { + return err + } + if err := requireEnumValue(params, "trigger", inputSubmitTriggerSet); err != nil { + return err + } + if err := requireExactString(input, "type", "text"); err != nil { + return err + } + if err := requireNonEmptyString(input, "text"); err != nil { + return err + } + if err := requireEnumValue(input, "input_mode", inputModeSet); err != nil { + return err + } + if err := validateContextEnvelope(params); err != nil { + return err + } + if err := validateVoiceMeta(params); err != nil { + return err + } + options, err := optionalObject(params, "options") + if err != nil { + return err + } + if err := optionalEnumValue(options, "preferred_delivery", deliveryTypeSet); err != nil { + return err + } + if err := optionalBoolField(options, "confirm_required"); err != nil { + return err + } + return nil } -type agentTaskStartOptions struct { - ConfirmRequired bool `json:"confirm_required,omitempty"` +func validateAgentTaskStartParams(params map[string]any) *rpcError { + if err := requireRequestMeta(params); err != nil { + return err + } + if err := optionalStringField(params, "session_id"); err != nil { + return err + } + input, err := requireObject(params, "input") + if err != nil { + return err + } + if err := requireEnumValue(params, "source", requestSourceSet); err != nil { + return err + } + if err := requireEnumValue(params, "trigger", requestTriggerSet); err != nil { + return err + } + if err := requireEnumValue(input, "type", inputTypeSet); err != nil { + return err + } + if err := validateTaskStartInputPayload(input, mapObject(params, "context")); err != nil { + return err + } + if err := validateContextEnvelope(params); err != nil { + return err + } + delivery, err := optionalObject(params, "delivery") + if err != nil { + return err + } + if err := optionalEnumValue(delivery, "preferred", deliveryTypeSet); err != nil { + return err + } + if err := optionalEnumValue(delivery, "fallback", deliveryTypeSet); err != nil { + return err + } + options, err := optionalObject(params, "options") + if err != nil { + return err + } + if err := optionalBoolField(options, "confirm_required"); err != nil { + return err + } + return nil } -// AgentTaskStartParams mirrors packages/protocol AgentTaskStartParams. The -// struct intentionally has no intent field, so unsupported client intent input -// is dropped before the orchestrator suggests the authoritative task intent. -type AgentTaskStartParams struct { - RequestMeta RequestMeta `json:"request_meta"` - SessionID string `json:"session_id,omitempty"` - Source string `json:"source,omitempty"` - Trigger string `json:"trigger,omitempty"` - Input agentTaskStartInput `json:"input"` - Context *inputContext `json:"context,omitempty"` - Delivery *deliveryPreference `json:"delivery,omitempty"` - Options *agentTaskStartOptions `json:"options,omitempty"` +func validateAgentTaskConfirmParams(params map[string]any) *rpcError { + if err := requireRequestMeta(params); err != nil { + return err + } + if err := requireNonEmptyString(params, "task_id"); err != nil { + return err + } + if err := optionalBoolField(params, "confirmed"); err != nil { + return err + } + if err := optionalStringField(params, "correction_text"); err != nil { + return err + } + correctedIntent, err := optionalObject(params, "corrected_intent") + if err != nil { + return err + } + if len(correctedIntent) == 0 { + return nil + } + if err := requireNonEmptyString(correctedIntent, "name"); err != nil { + return err + } + if _, err := optionalObject(correctedIntent, "arguments"); err != nil { + return err + } + return nil } -type intentPayload struct { - Name string `json:"name,omitempty"` - Arguments map[string]any `json:"arguments,omitempty"` +func validateAgentTaskDetailGetParams(params map[string]any) *rpcError { + if err := requireRequestMeta(params); err != nil { + return err + } + if err := requireNonEmptyString(params, "task_id"); err != nil { + return err + } + return nil } -// AgentTaskConfirmParams mirrors packages/protocol AgentTaskConfirmParams and -// keeps correction fields typed at the RPC boundary so malformed payloads fail -// before confirm-flow business logic tries to reinterpret them. -type AgentTaskConfirmParams struct { - RequestMeta RequestMeta `json:"request_meta"` - TaskID string `json:"task_id,omitempty"` - Confirmed bool `json:"confirmed,omitempty"` - CorrectedIntent *intentPayload `json:"corrected_intent,omitempty"` - CorrectionText *string `json:"correction_text,omitempty"` +func validateAgentTaskListParams(params map[string]any) *rpcError { + if err := requireRequestMeta(params); err != nil { + return err + } + if err := requireEnumValue(params, "group", taskListGroupSet); err != nil { + return err + } + if err := requireInteger(params, "limit"); err != nil { + return err + } + if err := requireInteger(params, "offset"); err != nil { + return err + } + if err := optionalEnumValue(params, "sort_by", taskListSortBySet); err != nil { + return err + } + if err := optionalEnumValue(params, "sort_order", taskListSortOrderSet); err != nil { + return err + } + return nil } -func decodeAgentInputSubmitParams(raw json.RawMessage) (map[string]any, *rpcError) { - var params AgentInputSubmitParams - return decodeTypedProtocolParams(raw, ¶ms) +// validateTaskStartInputPayload keeps the stable task-start DTO aligned with +// the context capture fallback rules. Structured starts must still carry the +// evidence that identifies the selected text, files, or error payload even when +// the caller provides that evidence via the broader context envelope. +func validateTaskStartInputPayload(input, context map[string]any) *rpcError { + switch stringValue(input, "type", "") { + case "text": + if !hasNonEmptyString(input, "text") && !hasNonEmptyString(context, "text") { + return invalidParamsError("text task input requires text") + } + case "text_selection": + selection := mapObject(context, "selection") + if !hasNonEmptyString(input, "text") && + !hasNonEmptyString(selection, "text") && + !hasNonEmptyString(context, "selection_text") { + return invalidParamsError("text_selection task input requires selected text") + } + case "file": + if !hasNonEmptyStringSlice(input, "files") && + !hasNonEmptyStringSlice(context, "files") && + !hasNonEmptyStringSlice(context, "file_paths") { + return invalidParamsError("file task input requires at least one file") + } + case "error": + errorContext := mapObject(context, "error") + if !hasNonEmptyString(input, "error_message") && + !hasNonEmptyString(errorContext, "message") { + return invalidParamsError("error task input requires an error message") + } + } + return nil } -func decodeAgentTaskStartParams(raw json.RawMessage) (map[string]any, *rpcError) { - var params AgentTaskStartParams - return decodeTypedProtocolParams(raw, ¶ms) +func validateContextEnvelope(params map[string]any) *rpcError { + input := mapObject(params, "input") + pageContext, err := optionalObject(input, "page_context") + if err != nil { + return err + } + if err := validatePageContext(pageContext); err != nil { + return err + } + context, err := optionalObject(params, "context") + if err != nil { + return err + } + if len(context) == 0 { + return nil + } + page, err := optionalObject(context, "page") + if err != nil { + return err + } + if err := validatePageContext(page); err != nil { + return err + } + screen, err := optionalObject(context, "screen") + if err != nil { + return err + } + if err := validateStringFields(screen, "summary", "screen_summary", "visible_text", "window_title", "hover_target"); err != nil { + return err + } + behavior, err := optionalObject(context, "behavior") + if err != nil { + return err + } + if err := validateStringFields(behavior, "last_action"); err != nil { + return err + } + if err := validateIntegerFields(behavior, "dwell_millis", "copy_count", "window_switch_count", "page_switch_count"); err != nil { + return err + } + selection, err := optionalObject(context, "selection") + if err != nil { + return err + } + if err := validateStringFields(selection, "text"); err != nil { + return err + } + errorContext, err := optionalObject(context, "error") + if err != nil { + return err + } + if err := validateStringFields(errorContext, "message"); err != nil { + return err + } + clipboard, err := optionalObject(context, "clipboard") + if err != nil { + return err + } + if err := validateStringFields(clipboard, "text"); err != nil { + return err + } + if err := validateStringFields(context, "text", "selection_text", "screen_summary", "clipboard_text", "hover_target", "last_action"); err != nil { + return err + } + if err := validateIntegerFields(context, "dwell_millis", "copy_count", "window_switch_count", "page_switch_count"); err != nil { + return err + } + if err := optionalStringSliceField(context, "files"); err != nil { + return err + } + if err := optionalStringSliceField(context, "file_paths"); err != nil { + return err + } + return nil } -func decodeAgentTaskConfirmParams(raw json.RawMessage) (map[string]any, *rpcError) { - var params AgentTaskConfirmParams - return decodeTypedProtocolParams(raw, ¶ms) +func validateVoiceMeta(params map[string]any) *rpcError { + voiceMeta, err := optionalObject(params, "voice_meta") + if err != nil { + return err + } + if err := validateStringFields(voiceMeta, "voice_session_id", "segment_id"); err != nil { + return err + } + if err := optionalBoolField(voiceMeta, "is_locked_session"); err != nil { + return err + } + if err := optionalNumberField(voiceMeta, "asr_confidence"); err != nil { + return err + } + return nil } -func decodeTypedProtocolParams(raw json.RawMessage, target any) (map[string]any, *rpcError) { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { - trimmed = []byte("{}") +func validatePageContext(values map[string]any) *rpcError { + if err := validateStringFields(values, "title", "app_name", "url", "process_path", "window_title", "visible_text", "hover_target"); err != nil { + return err } - if err := json.Unmarshal(trimmed, target); err != nil { - return nil, &rpcError{ - Code: errInvalidParams, - Message: "INVALID_PARAMS", - Detail: "params do not match the registered method dto", - TraceID: "trace_rpc_params", - } + if err := optionalEnumValue(values, "browser_kind", browserKindSet); err != nil { + return err + } + if err := validateIntegerFields(values, "process_id"); err != nil { + return err } - return protocolParamsMap(target) + return nil } -func protocolParamsMap(value any) (map[string]any, *rpcError) { - payload, err := json.Marshal(value) +func requireRequestMeta(params map[string]any) *rpcError { + requestMeta, err := requireObject(params, "request_meta") if err != nil { - return nil, &rpcError{ - Code: errInvalidParams, - Message: "INVALID_PARAMS", - Detail: "params could not be normalized", - TraceID: "trace_rpc_params", + return err + } + if err := requireNonEmptyString(requestMeta, "trace_id"); err != nil { + return err + } + if err := requireNonEmptyString(requestMeta, "client_time"); err != nil { + return err + } + return nil +} + +func requireObject(values map[string]any, key string) (map[string]any, *rpcError) { + raw, ok := values[key] + if !ok { + return nil, invalidParamsError("missing required object field: " + key) + } + object, ok := raw.(map[string]any) + if !ok { + return nil, invalidParamsError("field must be a json object: " + key) + } + return object, nil +} + +func mapObject(values map[string]any, key string) map[string]any { + raw, ok := values[key] + if !ok { + return map[string]any{} + } + object, ok := raw.(map[string]any) + if !ok { + return map[string]any{} + } + return object +} + +func optionalObject(values map[string]any, key string) (map[string]any, *rpcError) { + raw, ok := values[key] + if !ok || raw == nil { + return map[string]any{}, nil + } + object, ok := raw.(map[string]any) + if !ok { + return nil, invalidParamsError("field must be a json object: " + key) + } + return object, nil +} + +func requireNonEmptyString(values map[string]any, key string) *rpcError { + raw, ok := values[key] + if !ok { + return invalidParamsError("missing required string field: " + key) + } + value, ok := raw.(string) + if !ok || strings.TrimSpace(value) == "" { + return invalidParamsError("field must be a non-empty string: " + key) + } + return nil +} + +func requireInteger(values map[string]any, key string) *rpcError { + raw, ok := values[key] + if !ok { + return invalidParamsError("missing required integer field: " + key) + } + value, ok := raw.(float64) + if !ok || math.Trunc(value) != value { + return invalidParamsError("field must be an integer: " + key) + } + return nil +} + +func optionalIntegerField(values map[string]any, key string) *rpcError { + raw, ok := values[key] + if !ok || raw == nil { + return nil + } + value, ok := raw.(float64) + if !ok || math.Trunc(value) != value { + return invalidParamsError("field must be an integer: " + key) + } + return nil +} + +func optionalNumberField(values map[string]any, key string) *rpcError { + raw, ok := values[key] + if !ok || raw == nil { + return nil + } + switch raw.(type) { + case float64: + return nil + default: + return invalidParamsError("field must be a number: " + key) + } +} + +func optionalBoolField(values map[string]any, key string) *rpcError { + raw, ok := values[key] + if !ok || raw == nil { + return nil + } + if _, ok := raw.(bool); !ok { + return invalidParamsError("field must be a boolean: " + key) + } + return nil +} + +func optionalStringField(values map[string]any, key string) *rpcError { + raw, ok := values[key] + if !ok || raw == nil { + return nil + } + if _, ok := raw.(string); !ok { + return invalidParamsError("field must be a string: " + key) + } + return nil +} + +func optionalStringSliceField(values map[string]any, key string) *rpcError { + raw, ok := values[key] + if !ok || raw == nil { + return nil + } + items, ok := raw.([]any) + if !ok { + return invalidParamsError("field must be an array of strings: " + key) + } + for _, item := range items { + if _, ok := item.(string); !ok { + return invalidParamsError("field must be an array of strings: " + key) } } - var params map[string]any - if err := json.Unmarshal(payload, ¶ms); err != nil { - return nil, &rpcError{ - Code: errInvalidParams, - Message: "INVALID_PARAMS", - Detail: "params normalized to an invalid object", - TraceID: "trace_rpc_params", + return nil +} + +func validateStringFields(values map[string]any, keys ...string) *rpcError { + for _, key := range keys { + if err := optionalStringField(values, key); err != nil { + return err } } - return params, nil + return nil +} + +func validateIntegerFields(values map[string]any, keys ...string) *rpcError { + for _, key := range keys { + if err := optionalIntegerField(values, key); err != nil { + return err + } + } + return nil +} + +func hasNonEmptyString(values map[string]any, key string) bool { + raw, ok := values[key] + if !ok { + return false + } + value, ok := raw.(string) + return ok && strings.TrimSpace(value) != "" +} + +func hasNonEmptyStringSlice(values map[string]any, key string) bool { + raw, ok := values[key] + if !ok { + return false + } + items, ok := raw.([]any) + if !ok { + return false + } + for _, item := range items { + value, ok := item.(string) + if ok && strings.TrimSpace(value) != "" { + return true + } + } + return false +} + +func requireExactString(values map[string]any, key, expected string) *rpcError { + if err := requireNonEmptyString(values, key); err != nil { + return err + } + if values[key].(string) != expected { + return invalidParamsError("field must equal " + expected + ": " + key) + } + return nil +} + +func requireEnumValue(values map[string]any, key string, allowed map[string]struct{}) *rpcError { + if err := requireNonEmptyString(values, key); err != nil { + return err + } + return optionalEnumValue(values, key, allowed) +} + +func optionalEnumValue(values map[string]any, key string, allowed map[string]struct{}) *rpcError { + raw, ok := values[key] + if !ok { + return nil + } + value, ok := raw.(string) + if !ok || value == "" { + return invalidParamsError("field must be a non-empty string: " + key) + } + if _, ok := allowed[value]; !ok { + return invalidParamsError("field is outside the stable enum domain: " + key) + } + return nil +} + +func invalidParamsError(detail string) *rpcError { + return &rpcError{ + Code: errInvalidParams, + Message: "INVALID_PARAMS", + Detail: detail, + TraceID: "trace_rpc_params", + } } diff --git a/services/local-service/internal/rpc/handlers.go b/services/local-service/internal/rpc/handlers.go index 0b4b5cf1f..3d3076158 100644 --- a/services/local-service/internal/rpc/handlers.go +++ b/services/local-service/internal/rpc/handlers.go @@ -32,13 +32,13 @@ func (s *Server) handleAgentDeliveryOpen(params map[string]any) (any, *rpcError) // handleAgentInputSubmit handles agent.input.submit. func (s *Server) handleAgentInputSubmit(params map[string]any) (any, *rpcError) { - data, err := s.orchestrator.SubmitInput(params) + data, err := s.orchestrator.SubmitInputFromParams(params) return wrapOrchestratorResult(data, err) } // handleAgentTaskStart handles agent.task.start. func (s *Server) handleAgentTaskStart(params map[string]any) (any, *rpcError) { - data, err := s.orchestrator.StartTask(params) + data, err := s.orchestrator.StartTaskFromParams(params) return wrapOrchestratorResult(data, err) } @@ -69,7 +69,7 @@ func (s *Server) handleAgentTaskList(params map[string]any) (any, *rpcError) { // handleAgentTaskDetailGet handles agent.task.detail.get. func (s *Server) handleAgentTaskDetailGet(params map[string]any) (any, *rpcError) { - data, err := s.orchestrator.TaskDetailGet(params) + data, err := s.orchestrator.TaskDetailGetFromParams(params) return wrapOrchestratorResult(data, err) } diff --git a/services/local-service/internal/rpc/jsonrpc.go b/services/local-service/internal/rpc/jsonrpc.go index 93572a550..2226484b7 100644 --- a/services/local-service/internal/rpc/jsonrpc.go +++ b/services/local-service/internal/rpc/jsonrpc.go @@ -117,6 +117,8 @@ func newSuccessEnvelope(id json.RawMessage, data any, serverTime string) success JSONRPC: "2.0", ID: normalizeID(id), Result: resultEnvelope{ + // Keep typed DTO responses intact so the transport layer can marshal + // the already-normalized protocol object without rebuilding a map. Data: data, Meta: responseMeta{ ServerTime: serverTime, @@ -221,36 +223,53 @@ func boolValue(values map[string]any, key string, fallback bool) bool { return value } -// intValue safely reads a JSON-decoded numeric field and coerces it to int. +// intValue safely reads a numeric field and coerces it to int. +// Typed DTO normalization can already convert JSON numbers into Go ints before +// the rest of the RPC package inspects the normalized payload again. func intValue(values map[string]any, key string, fallback int) int { rawValue, ok := values[key] if !ok { return fallback } - value, ok := rawValue.(float64) - if !ok { + switch value := rawValue.(type) { + case float64: + return int(value) + case float32: + return int(value) + case int: + return value + case int32: + return int(value) + case int64: + return int(value) + default: return fallback } - - return int(value) } -// stringSliceValue converts a JSON-decoded array into a []string while -// skipping non-string entries and blank values. +// stringSliceValue converts a decoded array into a []string while skipping +// non-string entries and blank values. func stringSliceValue(rawValue any) []string { - values, ok := rawValue.([]any) - if !ok { - return nil - } - - result := make([]string, 0, len(values)) - for _, rawItem := range values { - item, ok := rawItem.(string) - if ok && item != "" { - result = append(result, item) + switch values := rawValue.(type) { + case []string: + result := make([]string, 0, len(values)) + for _, item := range values { + if item != "" { + result = append(result, item) + } + } + return result + case []any: + result := make([]string, 0, len(values)) + for _, rawItem := range values { + item, ok := rawItem.(string) + if ok && item != "" { + result = append(result, item) + } } + return result + default: + return nil } - - return result } diff --git a/services/local-service/internal/rpc/jsonrpc_test.go b/services/local-service/internal/rpc/jsonrpc_test.go index 5c9e56582..f6cf96269 100644 --- a/services/local-service/internal/rpc/jsonrpc_test.go +++ b/services/local-service/internal/rpc/jsonrpc_test.go @@ -6,6 +6,14 @@ import ( "testing" ) +type mappableProtocolResult struct { + Value string `json:"value"` +} + +func (result mappableProtocolResult) Map() map[string]any { + return map[string]any{"value": "mapped"} +} + func TestJSONRPCDecodeHelpers(t *testing.T) { req := requestEnvelope{ JSONRPC: "2.0", @@ -95,6 +103,17 @@ func TestJSONRPCEnvelopeHelpers(t *testing.T) { } } +func TestNewSuccessEnvelopeKeepsTypedProtocolResult(t *testing.T) { + response := newSuccessEnvelope(json.RawMessage(`"req-typed"`), mappableProtocolResult{Value: "typed"}, "2026-04-08T10:00:00Z") + marshaled := string(mustMarshal(t, response)) + if !strings.Contains(marshaled, `"value":"typed"`) { + t.Fatalf("expected success envelope to marshal typed payload directly, got %s", marshaled) + } + if strings.Contains(marshaled, `"value":"mapped"`) { + t.Fatalf("expected success envelope to avoid Map() fallback, got %s", marshaled) + } +} + func TestDispatchProtocolResponses(t *testing.T) { server := newTestServer() tests := []struct { diff --git a/services/local-service/internal/rpc/methods.go b/services/local-service/internal/rpc/methods.go index dd11b3dbd..e41d1b34a 100644 --- a/services/local-service/internal/rpc/methods.go +++ b/services/local-service/internal/rpc/methods.go @@ -58,38 +58,38 @@ func (s *Server) stableMethodRegistry() []registeredMethod { registered(methodAgentInputSubmit, decodeAgentInputSubmitParams, s.handleAgentInputSubmit), registered(methodAgentTaskStart, decodeAgentTaskStartParams, s.handleAgentTaskStart), registered(methodAgentTaskConfirm, decodeAgentTaskConfirmParams, s.handleAgentTaskConfirm), - registered(methodAgentRecommendationGet, decodeParams, s.handleAgentRecommendationGet), - registered(methodAgentRecommendationFeedbackSubmit, decodeParams, s.handleAgentRecommendationFeedbackSubmit), - registered(methodAgentTaskList, decodeParams, s.handleAgentTaskList), - registered(methodAgentTaskDetailGet, decodeParams, s.handleAgentTaskDetailGet), - registered(methodAgentTaskEventsList, decodeParams, s.handleAgentTaskEventsList), - registered(methodAgentTaskToolCallsList, decodeParams, s.handleAgentTaskToolCallsList), - registered(methodAgentTaskSteer, decodeParams, s.handleAgentTaskSteer), - registered(methodAgentTaskArtifactList, decodeParams, s.handleAgentTaskArtifactList), - registered(methodAgentTaskArtifactOpen, decodeParams, s.handleAgentTaskArtifactOpen), - registered(methodAgentTaskControl, decodeParams, s.handleAgentTaskControl), - registered(methodAgentTaskInspectorConfigGet, decodeParams, s.handleAgentTaskInspectorConfigGet), - registered(methodAgentTaskInspectorConfigUpdate, decodeParams, s.handleAgentTaskInspectorConfigUpdate), - registered(methodAgentTaskInspectorRun, decodeParams, s.handleAgentTaskInspectorRun), - registered(methodAgentNotepadList, decodeParams, s.handleAgentNotepadList), - registered(methodAgentNotepadConvertToTask, decodeParams, s.handleAgentNotepadConvertToTask), - registered(methodAgentNotepadUpdate, decodeParams, s.handleAgentNotepadUpdate), - registered(methodAgentDashboardOverviewGet, decodeParams, s.handleAgentDashboardOverviewGet), - registered(methodAgentDashboardModuleGet, decodeParams, s.handleAgentDashboardModuleGet), - registered(methodAgentMirrorOverviewGet, decodeParams, s.handleAgentMirrorOverviewGet), - registered(methodAgentSecuritySummaryGet, decodeParams, s.handleAgentSecuritySummaryGet), - registered(methodAgentSecurityAuditList, decodeParams, s.handleAgentSecurityAuditList), - registered(methodAgentSecurityRestorePointsList, decodeParams, s.handleAgentSecurityRestorePointsList), - registered(methodAgentSecurityRestoreApply, decodeParams, s.handleAgentSecurityRestoreApply), - registered(methodAgentSecurityPendingList, decodeParams, s.handleAgentSecurityPendingList), - registered(methodAgentSecurityRespond, decodeParams, s.handleAgentSecurityRespond), - registered(methodAgentDeliveryOpen, decodeParams, s.handleAgentDeliveryOpen), - registered(methodAgentSettingsGet, decodeParams, s.handleAgentSettingsGet), - registered(methodAgentSettingsUpdate, decodeParams, s.handleAgentSettingsUpdate), - registered(methodAgentSettingsModelValidate, decodeParams, s.handleAgentSettingsModelValidate), - registered(methodAgentPluginRuntimeList, decodeParams, s.handleAgentPluginRuntimeList), - registered(methodAgentPluginList, decodeParams, s.handleAgentPluginList), - registered(methodAgentPluginDetailGet, decodeParams, s.handleAgentPluginDetailGet), + registered(methodAgentRecommendationGet, decodeParamsRequiringRequestMeta, s.handleAgentRecommendationGet), + registered(methodAgentRecommendationFeedbackSubmit, decodeParamsRequiringRequestMeta, s.handleAgentRecommendationFeedbackSubmit), + registered(methodAgentTaskList, decodeAgentTaskListParams, s.handleAgentTaskList), + registered(methodAgentTaskDetailGet, decodeAgentTaskDetailGetParams, s.handleAgentTaskDetailGet), + registered(methodAgentTaskEventsList, decodeParamsRequiringRequestMeta, s.handleAgentTaskEventsList), + registered(methodAgentTaskToolCallsList, decodeParamsRequiringRequestMeta, s.handleAgentTaskToolCallsList), + registered(methodAgentTaskSteer, decodeParamsRequiringRequestMeta, s.handleAgentTaskSteer), + registered(methodAgentTaskArtifactList, decodeParamsRequiringRequestMeta, s.handleAgentTaskArtifactList), + registered(methodAgentTaskArtifactOpen, decodeParamsRequiringRequestMeta, s.handleAgentTaskArtifactOpen), + registered(methodAgentTaskControl, decodeParamsRequiringRequestMeta, s.handleAgentTaskControl), + registered(methodAgentTaskInspectorConfigGet, decodeParamsRequiringRequestMeta, s.handleAgentTaskInspectorConfigGet), + registered(methodAgentTaskInspectorConfigUpdate, decodeParamsRequiringRequestMeta, s.handleAgentTaskInspectorConfigUpdate), + registered(methodAgentTaskInspectorRun, decodeParamsRequiringRequestMeta, s.handleAgentTaskInspectorRun), + registered(methodAgentNotepadList, decodeParamsRequiringRequestMeta, s.handleAgentNotepadList), + registered(methodAgentNotepadConvertToTask, decodeParamsRequiringRequestMeta, s.handleAgentNotepadConvertToTask), + registered(methodAgentNotepadUpdate, decodeParamsRequiringRequestMeta, s.handleAgentNotepadUpdate), + registered(methodAgentDashboardOverviewGet, decodeParamsRequiringRequestMeta, s.handleAgentDashboardOverviewGet), + registered(methodAgentDashboardModuleGet, decodeParamsRequiringRequestMeta, s.handleAgentDashboardModuleGet), + registered(methodAgentMirrorOverviewGet, decodeParamsRequiringRequestMeta, s.handleAgentMirrorOverviewGet), + registered(methodAgentSecuritySummaryGet, decodeParamsRequiringRequestMeta, s.handleAgentSecuritySummaryGet), + registered(methodAgentSecurityAuditList, decodeParamsRequiringRequestMeta, s.handleAgentSecurityAuditList), + registered(methodAgentSecurityRestorePointsList, decodeParamsRequiringRequestMeta, s.handleAgentSecurityRestorePointsList), + registered(methodAgentSecurityRestoreApply, decodeParamsRequiringRequestMeta, s.handleAgentSecurityRestoreApply), + registered(methodAgentSecurityPendingList, decodeParamsRequiringRequestMeta, s.handleAgentSecurityPendingList), + registered(methodAgentSecurityRespond, decodeParamsRequiringRequestMeta, s.handleAgentSecurityRespond), + registered(methodAgentDeliveryOpen, decodeParamsRequiringRequestMeta, s.handleAgentDeliveryOpen), + registered(methodAgentSettingsGet, decodeParamsRequiringRequestMeta, s.handleAgentSettingsGet), + registered(methodAgentSettingsUpdate, decodeParamsRequiringRequestMeta, s.handleAgentSettingsUpdate), + registered(methodAgentSettingsModelValidate, decodeParamsRequiringRequestMeta, s.handleAgentSettingsModelValidate), + registered(methodAgentPluginRuntimeList, decodeParamsRequiringRequestMeta, s.handleAgentPluginRuntimeList), + registered(methodAgentPluginList, decodeParamsRequiringRequestMeta, s.handleAgentPluginList), + registered(methodAgentPluginDetailGet, decodeParamsRequiringRequestMeta, s.handleAgentPluginDetailGet), } } diff --git a/services/local-service/internal/rpc/notifications.go b/services/local-service/internal/rpc/notifications.go index 2fff48656..0eec351e8 100644 --- a/services/local-service/internal/rpc/notifications.go +++ b/services/local-service/internal/rpc/notifications.go @@ -2,6 +2,7 @@ package rpc import ( "encoding/json" + "reflect" "sort" "strings" ) @@ -138,23 +139,66 @@ func normalizeNotificationKey(method, taskID string, params map[string]any) map[ // collectTaskIDs walks arbitrary decoded payloads and gathers every field with // a task_id suffix. func collectTaskIDs(rawValue any, ids map[string]struct{}) { - switch value := rawValue.(type) { - case map[string]any: - for key, item := range value { - if strings.HasSuffix(key, "task_id") { - if taskID, ok := item.(string); ok && taskID != "" { - ids[taskID] = struct{}{} - } + collectTaskIDsValue(reflect.ValueOf(rawValue), "", ids) +} + +func collectTaskIDsValue(value reflect.Value, fieldName string, ids map[string]struct{}) { + if !value.IsValid() { + return + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return + } + value = value.Elem() + } + + switch value.Kind() { + case reflect.String: + if strings.HasSuffix(fieldName, "task_id") { + if taskID := strings.TrimSpace(value.String()); taskID != "" { + ids[taskID] = struct{}{} } - collectTaskIDs(item, ids) } - case []map[string]any: - for _, item := range value { - collectTaskIDs(item, ids) + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return + } + iter := value.MapRange() + for iter.Next() { + collectTaskIDsValue(iter.Value(), iter.Key().String(), ids) } - case []any: - for _, item := range value { - collectTaskIDs(item, ids) + case reflect.Slice, reflect.Array: + for index := 0; index < value.Len(); index++ { + collectTaskIDsValue(value.Index(index), fieldName, ids) + } + case reflect.Struct: + valueType := value.Type() + for index := 0; index < value.NumField(); index++ { + field := valueType.Field(index) + if !field.IsExported() { + continue + } + jsonName := notificationJSONFieldName(field) + if jsonName == "" { + continue + } + collectTaskIDsValue(value.Field(index), jsonName, ids) } } } + +func notificationJSONFieldName(field reflect.StructField) string { + tag := field.Tag.Get("json") + if tag == "-" { + return "" + } + if tag == "" { + return field.Name + } + name, _, _ := strings.Cut(tag, ",") + if name == "" { + return field.Name + } + return name +} diff --git a/services/local-service/internal/rpc/notifications_test.go b/services/local-service/internal/rpc/notifications_test.go index f4a3d63c8..e742d6f4b 100644 --- a/services/local-service/internal/rpc/notifications_test.go +++ b/services/local-service/internal/rpc/notifications_test.go @@ -5,6 +5,8 @@ import ( "reflect" "sort" "testing" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" ) func TestTaskIDsFromResponseCollectsNestedIDs(t *testing.T) { @@ -55,6 +57,18 @@ func TestOwnedTaskIDsForReplayClaimsResponseTaskIDsForTaskStart(t *testing.T) { } } +func TestOwnedTaskIDsForReplayClaimsTypedTaskStartResponseTaskIDs(t *testing.T) { + response := newSuccessEnvelope(json.RawMessage(`"req-task-start-typed"`), orchestrator.TaskEntryResponse{ + Task: &orchestrator.TaskDTO{TaskID: "task_started_typed"}, + }, "2026-04-08T10:00:00Z") + + taskIDs := ownedTaskIDsForReplay("agent.task.start", nil, response) + expected := []string{"task_started_typed"} + if !reflect.DeepEqual(taskIDs, expected) { + t.Fatalf("expected task.start to claim typed response task ids %v, got %v", expected, taskIDs) + } +} + func TestOwnedTaskIDsForReplayClaimsResponseTaskIDsForNotepadConvert(t *testing.T) { response := newSuccessEnvelope(json.RawMessage(`"req-notepad-convert"`), map[string]any{ "task": map[string]any{ diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index 85d4b6217..c3c557ac5 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -137,13 +137,16 @@ func TestStableMethodRegistryDispatchMatrix(t *testing.T) { methodAgentInputSubmit: decodeAgentInputSubmitParams, methodAgentTaskStart: decodeAgentTaskStartParams, methodAgentTaskConfirm: decodeAgentTaskConfirmParams, - methodAgentTaskControl: decodeParams, - methodAgentTaskDetailGet: decodeParams, - methodAgentTaskInspectorConfigGet: decodeParams, - methodAgentTaskInspectorRun: decodeParams, - methodAgentDeliveryOpen: decodeParams, - methodAgentSettingsGet: decodeParams, - methodAgentPluginDetailGet: decodeParams, + methodAgentTaskControl: decodeParamsRequiringRequestMeta, + methodAgentTaskDetailGet: decodeAgentTaskDetailGetParams, + methodAgentTaskList: decodeAgentTaskListParams, + methodAgentTaskInspectorConfigGet: decodeParamsRequiringRequestMeta, + methodAgentTaskInspectorRun: decodeParamsRequiringRequestMeta, + methodAgentDeliveryOpen: decodeParamsRequiringRequestMeta, + methodAgentSettingsGet: decodeParamsRequiringRequestMeta, + methodAgentPluginRuntimeList: decodeParamsRequiringRequestMeta, + methodAgentPluginList: decodeParamsRequiringRequestMeta, + methodAgentPluginDetailGet: decodeParamsRequiringRequestMeta, } for _, method := range server.stableMethodRegistry() { @@ -163,6 +166,693 @@ func TestStableMethodRegistryDispatchMatrix(t *testing.T) { } } +func TestAgentInputSubmitDTORejectsMissingStableFields(t *testing.T) { + _, rpcErr := decodeAgentInputSubmitParams(mustMarshal(t, map[string]any{ + "input": map[string]any{ + "type": "text", + "text": "inspect the page", + "input_mode": "text", + }, + })) + if rpcErr == nil { + t.Fatal("expected missing request_meta/source/trigger/context to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } +} + +func TestAgentInputSubmitDTORejectsOutOfDomainEnums(t *testing.T) { + _, rpcErr := decodeAgentInputSubmitParams(mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_input_submit_invalid_enum", + "client_time": "2026-05-09T12:00:00+08:00", + }, + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "inspect the page", + "input_mode": "dictation", + }, + "context": map[string]any{ + "page": map[string]any{ + "browser_kind": "firefox", + }, + }, + })) + if rpcErr == nil { + t.Fatal("expected invalid enum domains to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } +} + +func TestAgentTaskDetailGetDTORejectsBlankTaskID(t *testing.T) { + _, rpcErr := decodeAgentTaskDetailGetParams(mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_detail_blank_id", + "client_time": "2026-05-09T12:00:00+08:00", + }, + "task_id": "", + })) + if rpcErr == nil { + t.Fatal("expected blank task_id to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } +} + +func TestStableDTOsRejectIncompleteRequestMeta(t *testing.T) { + testCases := []struct { + name string + decode func(json.RawMessage) (map[string]any, *rpcError) + params map[string]any + }{ + { + name: "agent.input.submit", + decode: decodeAgentInputSubmitParams, + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_input_submit_missing_client_time", + }, + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "inspect the page", + "input_mode": "text", + }, + "context": map[string]any{}, + }, + }, + { + name: "agent.task.start", + decode: decodeAgentTaskStartParams, + params: map[string]any{ + "request_meta": map[string]any{ + "client_time": "2026-05-09T12:00:00+08:00", + }, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "selected content", + }, + }, + }, + { + name: "agent.task.detail.get", + decode: decodeAgentTaskDetailGetParams, + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_detail_missing_client_time", + }, + "task_id": "task_123", + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, rpcErr := testCase.decode(mustMarshal(t, testCase.params)) + if rpcErr == nil { + t.Fatal("expected incomplete request_meta to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } + }) + } +} + +func TestStableDTOsRejectWhitespaceOnlyRequiredStrings(t *testing.T) { + testCases := []struct { + name string + decode func(json.RawMessage) (map[string]any, *rpcError) + params map[string]any + }{ + { + name: "agent.input.submit trace_id", + decode: decodeAgentInputSubmitParams, + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": " ", + "client_time": "2026-05-09T12:00:00+08:00", + }, + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "inspect the page", + "input_mode": "text", + }, + "context": map[string]any{}, + }, + }, + { + name: "agent.task.detail.get task_id", + decode: decodeAgentTaskDetailGetParams, + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_detail_whitespace", + "client_time": "2026-05-09T12:00:00+08:00", + }, + "task_id": " ", + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, rpcErr := testCase.decode(mustMarshal(t, testCase.params)) + if rpcErr == nil { + t.Fatal("expected whitespace-only required strings to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } + }) + } +} + +func TestStableDTOsRejectNonStringOptionalSessionID(t *testing.T) { + testCases := []struct { + name string + decode func(json.RawMessage) (map[string]any, *rpcError) + params map[string]any + }{ + { + name: "agent.input.submit session_id", + decode: decodeAgentInputSubmitParams, + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_input_submit_bad_session", + "client_time": "2026-05-09T12:00:00+08:00", + }, + "session_id": 123, + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "inspect the page", + "input_mode": "text", + }, + "context": map[string]any{}, + }, + }, + { + name: "agent.task.start session_id", + decode: decodeAgentTaskStartParams, + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_bad_session", + "client_time": "2026-05-09T12:00:00+08:00", + }, + "session_id": 123, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "selected content", + }, + "context": map[string]any{ + "selection": map[string]any{ + "text": "selected content", + }, + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, rpcErr := testCase.decode(mustMarshal(t, testCase.params)) + if rpcErr == nil { + t.Fatal("expected non-string session_id to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } + }) + } +} + +func TestDecodeParamsRequiringRequestMetaMatchesStableContract(t *testing.T) { + _, rpcErr := decodeParamsRequiringRequestMeta(mustMarshal(t, map[string]any{ + "group": "unfinished", + "limit": 20, + "offset": 0, + })) + if rpcErr == nil { + t.Fatal("expected stable decode helper to reject missing request_meta") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } + + params, rpcErr := decodeParamsRequiringRequestMeta(mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_list_valid_meta", + "client_time": "2026-05-09T12:00:00+08:00", + }, + "group": "unfinished", + "limit": 20, + "offset": 0, + })) + if rpcErr != nil { + t.Fatalf("expected valid request_meta to pass stable decode helper, got %+v", rpcErr) + } + if requestTraceID(params) != "trace_task_list_valid_meta" { + t.Fatalf("expected decode helper to preserve request_meta, got %+v", params) + } +} + +func TestAgentTaskListDTORejectsMissingStableFields(t *testing.T) { + _, rpcErr := decodeAgentTaskListParams(mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_list_missing_fields", + "client_time": "2026-05-10T00:00:00Z", + }, + })) + if rpcErr == nil { + t.Fatal("expected missing task-list fields to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } +} + +func TestAgentTaskListDTORejectsOutOfDomainEnums(t *testing.T) { + _, rpcErr := decodeAgentTaskListParams(mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_list_invalid_enum", + "client_time": "2026-05-10T00:00:00Z", + }, + "group": "all", + "limit": 20, + "offset": 0, + "sort_by": "created_at", + "sort_order": "descending", + })) + if rpcErr == nil { + t.Fatal("expected invalid task-list enums to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } +} + +func TestAgentTaskListDTOAcceptsStableShape(t *testing.T) { + params, rpcErr := decodeAgentTaskListParams(mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_list_valid", + "client_time": "2026-05-10T00:00:00Z", + }, + "group": "unfinished", + "limit": 20, + "offset": 0, + "sort_by": "updated_at", + "sort_order": "desc", + })) + if rpcErr != nil { + t.Fatalf("expected valid task-list params to pass, got %+v", rpcErr) + } + if stringValue(params, "group", "") != "unfinished" || intValue(params, "limit", -1) != 20 || intValue(params, "offset", -1) != 0 { + t.Fatalf("expected task-list params to survive decoding, got %+v", params) + } +} + +func TestAgentTaskStartDTORejectsOutOfDomainPageContextBrowserWithoutContext(t *testing.T) { + _, rpcErr := decodeAgentTaskStartParams(mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_invalid_page_context_browser", + "client_time": "2026-05-09T12:00:00+08:00", + }, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "selected content", + "page_context": map[string]any{ + "browser_kind": "firefox", + }, + }, + })) + if rpcErr == nil { + t.Fatal("expected out-of-domain page_context browser_kind to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } +} + +func TestTypedEntryDTOsRejectMalformedDeclaredNestedFields(t *testing.T) { + testCases := []struct { + name string + decode func(json.RawMessage) (map[string]any, *rpcError) + params map[string]any + }{ + { + name: "task.start context.page.process_id", + decode: decodeAgentTaskStartParams, + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_invalid_process_id", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "selected content", + }, + "context": map[string]any{ + "page": map[string]any{ + "process_id": "42", + }, + "selection": map[string]any{ + "text": "selected content", + }, + }, + }, + }, + { + name: "task.start context.behavior.dwell_millis", + decode: decodeAgentTaskStartParams, + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_invalid_dwell_millis", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "selected content", + }, + "context": map[string]any{ + "behavior": map[string]any{ + "dwell_millis": "oops", + }, + "selection": map[string]any{ + "text": "selected content", + }, + }, + }, + }, + { + name: "input.submit voice_meta.asr_confidence", + decode: decodeAgentInputSubmitParams, + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_input_submit_invalid_asr_confidence", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "inspect the page", + "input_mode": "text", + }, + "context": map[string]any{}, + "voice_meta": map[string]any{ + "asr_confidence": "0.9", + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, rpcErr := testCase.decode(mustMarshal(t, testCase.params)) + if rpcErr == nil { + t.Fatal("expected malformed nested dto field to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } + }) + } +} + +func TestAgentInputSubmitDTORejectsBlankInputText(t *testing.T) { + _, rpcErr := decodeAgentInputSubmitParams(mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_input_submit_blank_text", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": " ", + "input_mode": "text", + }, + "context": map[string]any{}, + })) + if rpcErr == nil { + t.Fatal("expected blank submit text to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } +} + +func TestAgentTaskStartDTORejectsMissingTypeSpecificPayload(t *testing.T) { + testCases := []struct { + name string + params map[string]any + }{ + { + name: "text without text", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_text_missing_text", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + }, + }, + }, + { + name: "text_selection without selected text", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_selection_missing_text", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + }, + }, + }, + { + name: "file without files", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_file_missing_files", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "file_drop", + "input": map[string]any{ + "type": "file", + }, + }, + }, + { + name: "error without error message", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_error_missing_message", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "error_detected", + "input": map[string]any{ + "type": "error", + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, rpcErr := decodeAgentTaskStartParams(mustMarshal(t, testCase.params)) + if rpcErr == nil { + t.Fatal("expected missing type-specific payload to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } + }) + } +} + +func TestAgentTaskStartDTOAcceptsContextBackedTypeSpecificPayload(t *testing.T) { + testCases := []struct { + name string + params map[string]any + }{ + { + name: "text backed by context.text", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_text_context", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + }, + "context": map[string]any{ + "text": "summarize this page", + }, + }, + }, + { + name: "text_selection backed by context.selection", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_selection_context", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + }, + "context": map[string]any{ + "selection": map[string]any{ + "text": "selected content", + }, + }, + }, + }, + { + name: "file backed by context.files", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_file_context", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "file_drop", + "input": map[string]any{ + "type": "file", + }, + "context": map[string]any{ + "files": []any{"workspace/report.md"}, + }, + }, + }, + { + name: "error backed by context.error.message", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_error_context", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "error_detected", + "input": map[string]any{ + "type": "error", + }, + "context": map[string]any{ + "error": map[string]any{ + "message": "build failed", + }, + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + if _, rpcErr := decodeAgentTaskStartParams(mustMarshal(t, testCase.params)); rpcErr != nil { + t.Fatalf("expected context-backed payload to pass dto validation, got %+v", rpcErr) + } + }) + } +} + +func TestAgentTaskStartDTORejectsLegacyAliasesOutsideStableContract(t *testing.T) { + testCases := []struct { + name string + params map[string]any + }{ + { + name: "input.selection_text", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_legacy_selection_text", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "selection_text": "selected content", + }, + }, + }, + { + name: "input.file_paths", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_legacy_file_paths", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "file_drop", + "input": map[string]any{ + "type": "file", + "file_paths": []any{"workspace/report.md"}, + }, + }, + }, + { + name: "context.error_text", + params: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_legacy_error_text", + "client_time": "2026-05-10T00:00:00Z", + }, + "source": "floating_ball", + "trigger": "error_detected", + "input": map[string]any{ + "type": "error", + }, + "context": map[string]any{ + "error_text": "build failed", + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, rpcErr := decodeAgentTaskStartParams(mustMarshal(t, testCase.params)) + if rpcErr == nil { + t.Fatal("expected legacy-only alias payload to return invalid params") + } + if rpcErr.Code != errInvalidParams || rpcErr.Message != "INVALID_PARAMS" { + t.Fatalf("expected invalid params error, got %+v", rpcErr) + } + }) + } +} + func protocolStableMethodBlock(source string) string { start := strings.Index(source, "RPC_METHODS_STABLE") end := strings.Index(source, "RPC_METHODS_PLANNED") diff --git a/services/local-service/internal/rpc/rpc_test_helpers_test.go b/services/local-service/internal/rpc/rpc_test_helpers_test.go index bc0d6b953..0be25fc8b 100644 --- a/services/local-service/internal/rpc/rpc_test_helpers_test.go +++ b/services/local-service/internal/rpc/rpc_test_helpers_test.go @@ -207,6 +207,21 @@ func newTestServerWithDependencies(client model.Client, storageService *storage. return server, toolRegistry, pluginService } +func rpcRequestMeta(traceID string) map[string]any { + return map[string]any{ + "trace_id": traceID, + "client_time": "2026-05-10T00:00:00Z", + } +} + +func startTaskForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { + response, err := s.StartTask(orchestrator.StartTaskRequestFromParams(params)) + if err != nil { + return nil, err + } + return response.Map(), nil +} + func mustMarshal(t *testing.T, value any) json.RawMessage { t.Helper() encoded, err := json.Marshal(value) @@ -216,6 +231,40 @@ func mustMarshal(t *testing.T, value any) json.RawMessage { return encoded } +func protocolMap(t *testing.T, value any) map[string]any { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal protocol value: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("decode protocol value: %v", err) + } + return decoded +} + +func protocolMapSlice(t *testing.T, value any) []map[string]any { + t.Helper() + switch typed := value.(type) { + case []map[string]any: + return typed + case []any: + result := make([]map[string]any, 0, len(typed)) + for _, item := range typed { + mapped, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected map item, got %#v", item) + } + result = append(result, mapped) + } + return result + default: + t.Fatalf("expected protocol map slice, got %#v", value) + return nil + } +} + func numericValue(t *testing.T, value any) int { t.Helper() switch typed := value.(type) { diff --git a/services/local-service/internal/rpc/server_stream_coordination_test.go b/services/local-service/internal/rpc/server_stream_coordination_test.go index 767462790..ce99adc73 100644 --- a/services/local-service/internal/rpc/server_stream_coordination_test.go +++ b/services/local-service/internal/rpc/server_stream_coordination_test.go @@ -24,33 +24,51 @@ func TestHandleStreamConnSerializesTaskStartingRequestsOnSharedConnection(t *tes name: "input submit", method: "agent.input.submit", firstParams: map[string]any{ - "session_id": "sess_serialized_submit", + "request_meta": rpcRequestMeta("trace_serialized_submit_first"), + "session_id": "sess_serialized_submit", + "source": "floating_ball", + "trigger": "hover_text_input", "input": map[string]any{ - "type": "text", - "text": "first submit", + "type": "text", + "text": "first submit", + "input_mode": "text", }, + "context": map[string]any{}, }, secondParams: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_serialized_submit_second", + "client_time": "2026-05-10T10:00:01Z", + }, "session_id": "sess_serialized_submit", + "source": "floating_ball", + "trigger": "hover_text_input", "input": map[string]any{ - "type": "text", - "text": "second submit", + "type": "text", + "text": "second submit", + "input_mode": "text", }, + "context": map[string]any{}, }, }, { name: "task start", method: "agent.task.start", firstParams: map[string]any{ - "session_id": "sess_serialized_start", - "source": "floating_ball", - "trigger": "text_selected_click", + "request_meta": rpcRequestMeta("trace_serialized_start_first"), + "session_id": "sess_serialized_start", + "source": "floating_ball", + "trigger": "text_selected_click", "input": map[string]any{ "type": "text_selection", "text": "first selection", }, }, secondParams: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_serialized_start_second", + "client_time": "2026-05-10T10:00:01Z", + }, "session_id": "sess_serialized_start", "source": "floating_ball", "trigger": "text_selected_click", @@ -185,7 +203,7 @@ func TestHandleStreamConnSerializesTaskStartingRequestsOnSharedConnection(t *tes func TestHandleStreamConnReplaysLateTaskNotificationsBeforeQueuedSameTaskFollowUp(t *testing.T) { server := newTestServer() - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_late_task_replay", "source": "floating_ball", "trigger": "hover_text_input", @@ -237,9 +255,10 @@ func TestHandleStreamConnReplaysLateTaskNotificationsBeforeQueuedSameTaskFollowU ID: json.RawMessage(`"req-late-task-starter"`), Method: "agent.task.start", Params: mustMarshal(t, map[string]any{ - "session_id": "sess_late_task_response_owner", - "source": "floating_ball", - "trigger": "text_selected_click", + "request_meta": rpcRequestMeta("trace_late_task_starter"), + "session_id": "sess_late_task_response_owner", + "source": "floating_ball", + "trigger": "text_selected_click", "input": map[string]any{ "type": "text_selection", "text": "start a task on the shared stream", @@ -310,7 +329,7 @@ func TestHandleStreamConnReplaysLateTaskNotificationsBeforeQueuedSameTaskFollowU func TestHandleStreamConnTaskListDoesNotStealBufferedNotifications(t *testing.T) { server := newTestServer() - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_task_list_replay_owner", "source": "floating_ball", "trigger": "hover_text_input", @@ -363,7 +382,12 @@ func TestHandleStreamConnTaskListDoesNotStealBufferedNotifications(t *testing.T) JSONRPC: "2.0", ID: json.RawMessage(`"req-task-list-owned"`), Method: "agent.task.list", - Params: mustMarshal(t, map[string]any{}), + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_list_owned"), + "group": "unfinished", + "limit": 20, + "offset": 0, + }), }); err != nil { t.Fatalf("encode task.list request: %v", err) } @@ -455,7 +479,7 @@ func TestHandleStreamConnKeepsQueuedReadsResponsiveWhileLoopTaskRuns(t *testing. startTask := func(sessionID string) string { t.Helper() - result, err := server.orchestrator.StartTask(map[string]any{ + result, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": sessionID, "source": "floating_ball", "trigger": "text_selected_click", @@ -514,8 +538,9 @@ func TestHandleStreamConnKeepsQueuedReadsResponsiveWhileLoopTaskRuns(t *testing. ID: json.RawMessage(`"req-loop-blocked"`), Method: "agent.task.confirm", Params: mustMarshal(t, map[string]any{ - "task_id": taskA, - "confirmed": false, + "request_meta": rpcRequestMeta("trace_task_confirm_loop_blocked"), + "task_id": taskA, + "confirmed": false, "corrected_intent": map[string]any{ "name": "agent_loop", "arguments": map[string]any{}, @@ -537,7 +562,8 @@ func TestHandleStreamConnKeepsQueuedReadsResponsiveWhileLoopTaskRuns(t *testing. ID: json.RawMessage(`"req-task-detail-queued"`), Method: "agent.task.detail.get", Params: mustMarshal(t, map[string]any{ - "task_id": taskB, + "request_meta": rpcRequestMeta("trace_task_detail_loop_blocked"), + "task_id": taskB, }), } if err := encoder.Encode(detailRequest); err != nil { @@ -598,7 +624,7 @@ func TestHandleStreamConnSerializesConcurrentRequestsForSameTask(t *testing.T) { } server := newTestServerWithModelClient(modelClient) - result, err := server.orchestrator.StartTask(map[string]any{ + result, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_pipe_same_task", "source": "floating_ball", "trigger": "text_selected_click", @@ -665,8 +691,9 @@ func TestHandleStreamConnSerializesConcurrentRequestsForSameTask(t *testing.T) { ID: json.RawMessage(`"req-loop-same-task"`), Method: "agent.task.confirm", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - "confirmed": false, + "request_meta": rpcRequestMeta("trace_task_confirm_same_task"), + "task_id": taskID, + "confirmed": false, "corrected_intent": map[string]any{ "name": "agent_loop", "arguments": map[string]any{}, @@ -688,7 +715,8 @@ func TestHandleStreamConnSerializesConcurrentRequestsForSameTask(t *testing.T) { ID: json.RawMessage(`"req-task-detail-same-task"`), Method: "agent.task.detail.get", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, + "request_meta": rpcRequestMeta("trace_task_detail_same_task"), + "task_id": taskID, }), } if err := encoder.Encode(detailRequest); err != nil { diff --git a/services/local-service/internal/rpc/server_stream_notifications_test.go b/services/local-service/internal/rpc/server_stream_notifications_test.go index 7ecd95491..abd0e9296 100644 --- a/services/local-service/internal/rpc/server_stream_notifications_test.go +++ b/services/local-service/internal/rpc/server_stream_notifications_test.go @@ -15,7 +15,7 @@ import ( // are emitted on the stream connection after task confirmation enters waiting_auth. func TestHandleStreamConnEmitsApprovalNotifications(t *testing.T) { server := newTestServer() - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -45,8 +45,9 @@ func TestHandleStreamConnEmitsApprovalNotifications(t *testing.T) { ID: json.RawMessage(`"req-1"`), Method: "agent.task.confirm", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - "confirmed": false, + "request_meta": rpcRequestMeta("trace_task_confirm_approval"), + "task_id": taskID, + "confirmed": false, "corrected_intent": map[string]any{ "name": "write_file", "arguments": map[string]any{ @@ -65,7 +66,7 @@ func TestHandleStreamConnEmitsApprovalNotifications(t *testing.T) { if err := decoder.Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } - if response.Result.Data.(map[string]any)["task"].(map[string]any)["status"] != "waiting_auth" { + if protocolMap(t, response.Result.Data)["task"].(map[string]any)["status"] != "waiting_auth" { t.Fatalf("expected waiting_auth task status in response") } @@ -91,7 +92,7 @@ func TestHandleStreamConnEmitsApprovalNotifications(t *testing.T) { func TestHandleStreamConnEmitsLoopLifecycleNotifications(t *testing.T) { server := newTestServer() - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_loop_notify", "source": "floating_ball", "trigger": "hover_text_input", @@ -130,7 +131,8 @@ func TestHandleStreamConnEmitsLoopLifecycleNotifications(t *testing.T) { ID: json.RawMessage(`"req-task-detail"`), Method: "agent.task.detail.get", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, + "request_meta": rpcRequestMeta("trace_task_detail_loop_notify"), + "task_id": taskID, }), } @@ -142,7 +144,7 @@ func TestHandleStreamConnEmitsLoopLifecycleNotifications(t *testing.T) { if err := decoder.Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } - if response.Result.Data.(map[string]any)["task"].(map[string]any)["task_id"] != taskID { + if protocolMap(t, response.Result.Data)["task"].(map[string]any)["task_id"] != taskID { t.Fatalf("expected task detail response for %s, got %+v", taskID, response) } @@ -174,7 +176,7 @@ func TestHandleStreamConnStreamsLoopLifecycleNotificationsBeforeResponse(t *test generateToolSeen: make(chan struct{}), } server := newTestServerWithModelClient(modelClient) - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_loop_stream", "source": "floating_ball", "trigger": "text_selected_click", @@ -203,8 +205,9 @@ func TestHandleStreamConnStreamsLoopLifecycleNotificationsBeforeResponse(t *test ID: json.RawMessage(`"req-loop-stream"`), Method: "agent.task.confirm", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - "confirmed": false, + "request_meta": rpcRequestMeta("trace_task_confirm_loop_stream"), + "task_id": taskID, + "confirmed": false, "corrected_intent": map[string]any{ "name": "agent_loop", "arguments": map[string]any{}, @@ -287,11 +290,16 @@ func TestHandleStreamConnStreamsLoopLifecycleNotificationsBeforeResponseForSubmi ID: json.RawMessage(`"req-input-submit-loop-stream"`), Method: "agent.input.submit", Params: mustMarshal(t, map[string]any{ - "session_id": "sess_input_submit_loop_stream", + "request_meta": rpcRequestMeta("trace_input_submit_loop_stream"), + "session_id": "sess_input_submit_loop_stream", + "source": "floating_ball", + "trigger": "hover_text_input", "input": map[string]any{ - "type": "text", - "text": "inspect this workspace and answer directly", + "type": "text", + "text": "inspect this workspace and answer directly", + "input_mode": "text", }, + "context": map[string]any{}, "options": map[string]any{ "confirm_required": false, }, @@ -373,11 +381,16 @@ func TestHandleStreamConnDoesNotReplayStreamedRuntimeNotificationsAfterResponse( ID: json.RawMessage(`"req-loop-no-replay"`), Method: "agent.input.submit", Params: mustMarshal(t, map[string]any{ - "session_id": "sess_input_submit_no_replay", + "request_meta": rpcRequestMeta("trace_input_submit_no_replay"), + "session_id": "sess_input_submit_no_replay", + "source": "floating_ball", + "trigger": "hover_text_input", "input": map[string]any{ - "type": "text", - "text": "inspect this workspace and answer directly", + "type": "text", + "text": "inspect this workspace and answer directly", + "input_mode": "text", }, + "context": map[string]any{}, "options": map[string]any{ "confirm_required": false, }, @@ -451,7 +464,7 @@ func TestHandleStreamConnFiltersRuntimeNotificationsToRequestTask(t *testing.T) startTask := func(sessionID string) string { t.Helper() - result, err := server.orchestrator.StartTask(map[string]any{ + result, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": sessionID, "source": "floating_ball", "trigger": "text_selected_click", @@ -482,8 +495,9 @@ func TestHandleStreamConnFiltersRuntimeNotificationsToRequestTask(t *testing.T) ID: json.RawMessage(`"req-loop-scope"`), Method: "agent.task.confirm", Params: mustMarshal(t, map[string]any{ - "task_id": taskA, - "confirmed": false, + "request_meta": rpcRequestMeta("trace_task_confirm_loop_scope"), + "task_id": taskA, + "confirmed": false, "corrected_intent": map[string]any{ "name": "agent_loop", "arguments": map[string]any{}, diff --git a/services/local-service/internal/rpc/server_transport_test.go b/services/local-service/internal/rpc/server_transport_test.go index 413b69a2a..2f4729aca 100644 --- a/services/local-service/internal/rpc/server_transport_test.go +++ b/services/local-service/internal/rpc/server_transport_test.go @@ -60,7 +60,10 @@ func TestHandleStreamConnServesJSONRPCSuccess(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-stream-success"`), Method: "agent.settings.get", - Params: mustMarshal(t, map[string]any{"scope": "all"}), + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_stream_settings_get"), + "scope": "all", + }), } if err := json.NewEncoder(right).Encode(request); err != nil { t.Fatalf("encode stream request: %v", err) @@ -97,11 +100,19 @@ func TestHandleStreamConnSkipsBufferedLiveRuntimeReplay(t *testing.T) { ID: json.RawMessage(`"req-stream-runtime-no-replay"`), Method: "agent.input.submit", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_stream_runtime_no_replay", + "client_time": "2026-05-10T10:00:00Z", + }, "session_id": "sess_stream_runtime_no_replay", + "source": "floating_ball", + "trigger": "hover_text_input", "input": map[string]any{ - "type": "text", - "text": "inspect this workspace and answer directly", + "type": "text", + "text": "inspect this workspace and answer directly", + "input_mode": "text", }, + "context": map[string]any{}, "options": map[string]any{ "confirm_required": false, }, @@ -334,7 +345,7 @@ func TestHandleHTTPRPCCoversMethodDecodeAndSuccess(t *testing.T) { } successRecorder := httptest.NewRecorder() - successRequest := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(`{"jsonrpc":"2.0","id":"req-http-rpc","method":"agent.settings.get","params":{"scope":"all"}}`)) + successRequest := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(`{"jsonrpc":"2.0","id":"req-http-rpc","method":"agent.settings.get","params":{"request_meta":{"trace_id":"trace_http_settings_get","client_time":"2026-05-10T00:00:00Z"},"scope":"all"}}`)) server.handleHTTPRPC(successRecorder, successRequest) if successRecorder.Code != http.StatusOK { t.Fatalf("expected rpc post to return 200, got %d", successRecorder.Code) @@ -363,7 +374,7 @@ func TestHandleDebugEventsCoversValidationAndSuccess(t *testing.T) { t.Fatalf("expected unknown task_id to return 404, got %d", notFoundRecorder.Code) } - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_debug_events", "source": "floating_ball", "trigger": "hover_text_input", @@ -425,7 +436,7 @@ func TestHandleDebugEventStreamCoversValidationSuccessAndError(t *testing.T) { t.Fatalf("expected SSE error event, got %q", errorRecorder.Body.String()) } - startResult, err := server.orchestrator.StartTask(map[string]any{ + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ "session_id": "sess_stream_success", "source": "floating_ball", "trigger": "hover_text_input", diff --git a/services/local-service/internal/taskcontext/service.go b/services/local-service/internal/taskcontext/service.go index 8ac27aa43..54228396e 100644 --- a/services/local-service/internal/taskcontext/service.go +++ b/services/local-service/internal/taskcontext/service.go @@ -1,7 +1,11 @@ // Package taskcontext captures and normalizes task-facing input snapshots. package taskcontext -import "strings" +import ( + "strings" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/urlutil" +) // TaskContextSnapshot aggregates the normalized request context that the main // task pipeline uses for intent inference and orchestration. @@ -53,9 +57,6 @@ func (s *CaptureService) Capture(params map[string]any) TaskContextSnapshot { input := mapValue(params, "input") contextValue := mapValue(params, "context") selection := mapValue(contextValue, "selection") - if len(selection) == 0 { - selection = mapValue(input, "selection") - } pageContext := mapValue(input, "page_context") pageFallback := mapValue(contextValue, "page") errorValue := mapValue(contextValue, "error") @@ -66,7 +67,6 @@ func (s *CaptureService) Capture(params map[string]any) TaskContextSnapshot { selectionText := firstNonEmpty( stringValue(selection, "text"), stringValue(contextValue, "selection_text"), - stringValue(input, "selection_text"), ) text := firstNonEmpty( stringValue(input, "text"), @@ -75,14 +75,12 @@ func (s *CaptureService) Capture(params map[string]any) TaskContextSnapshot { errorText := firstNonEmpty( stringValue(input, "error_message"), stringValue(errorValue, "message"), - stringValue(contextValue, "error_text"), ) files := dedupeStrings(append( append(stringSliceValue(input["files"]), stringSliceValue(contextValue["files"])...), - stringSliceValue(input["file_paths"])..., + stringSliceValue(contextValue["file_paths"])..., )) - files = dedupeStrings(append(files, stringSliceValue(contextValue["file_paths"])...)) inputType := firstNonEmpty(stringValue(input, "type"), inferInputType(text, selectionText, errorText, files)) if inputType == "text_selection" && text == "" { @@ -102,7 +100,7 @@ func (s *CaptureService) Capture(params map[string]any) TaskContextSnapshot { ErrorText: errorText, Files: files, PageTitle: firstNonEmpty(stringValue(pageContext, "title"), stringValue(pageFallback, "title")), - PageURL: firstNonEmpty(stringValue(pageContext, "url"), stringValue(pageFallback, "url")), + PageURL: urlutil.SanitizeContextURL(firstNonEmpty(stringValue(pageContext, "url"), stringValue(pageFallback, "url"))), AppName: firstNonEmpty(stringValue(pageContext, "app_name"), stringValue(pageFallback, "app_name")), BrowserKind: firstNonEmpty(stringValue(pageContext, "browser_kind"), stringValue(pageFallback, "browser_kind")), ProcessPath: firstNonEmpty(stringValue(pageContext, "process_path"), stringValue(pageFallback, "process_path")), diff --git a/services/local-service/internal/taskcontext/service_test.go b/services/local-service/internal/taskcontext/service_test.go index d61c70df6..a8de30137 100644 --- a/services/local-service/internal/taskcontext/service_test.go +++ b/services/local-service/internal/taskcontext/service_test.go @@ -16,7 +16,7 @@ func TestServiceCaptureNormalizesNestedContext(t *testing.T) { }, "page": map[string]any{ "title": " Editor ", - "url": " https://example.com/doc ", + "url": " https://user:pass@example.com/doc?tab=1#focus ", "app_name": " desktop ", "browser_kind": " chrome ", "process_path": " C:/Program Files/Google/Chrome/Application/chrome.exe ", @@ -80,7 +80,7 @@ func TestServiceCapturePrefersInputPageContextAndFlatFallbackSignals(t *testing. "text": "看看当前屏幕上哪里出错了", "page_context": map[string]any{ "title": " Build Pipeline ", - "url": " https://example.com/build ", + "url": " https://example.com/build?job=42#logs ", "app_name": " Chrome ", "browser_kind": " edge ", "process_path": " C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe ", @@ -157,6 +157,31 @@ func TestServiceSnapshotAndCaptureInferenceHelpers(t *testing.T) { } } +func TestServiceCaptureIgnoresRetiredTaskEntryAliases(t *testing.T) { + service := NewCaptureService() + + snapshot := service.Capture(map[string]any{ + "input": map[string]any{ + "type": "text_selection", + "selection_text": "legacy selected text", + "file_paths": []any{"workspace/legacy.txt"}, + }, + "context": map[string]any{ + "error_text": "legacy build failed", + }, + }) + + if snapshot.SelectionText != "" { + t.Fatalf("expected retired input.selection_text alias to be ignored, got %q", snapshot.SelectionText) + } + if len(snapshot.Files) != 0 { + t.Fatalf("expected retired input.file_paths alias to be ignored, got %+v", snapshot.Files) + } + if snapshot.ErrorText != "" { + t.Fatalf("expected retired context.error_text alias to be ignored, got %q", snapshot.ErrorText) + } +} + func TestTaskContextPrimitiveHelpersCoverAdditionalBranches(t *testing.T) { if values := stringSliceValue([]string{" demo ", "demo", "notes"}); len(values) != 2 || values[0] != "demo" || values[1] != "notes" { t.Fatalf("expected []string branch to trim and dedupe, got %+v", values) diff --git a/services/local-service/internal/urlutil/context_url.go b/services/local-service/internal/urlutil/context_url.go new file mode 100644 index 000000000..1ca8ceefa --- /dev/null +++ b/services/local-service/internal/urlutil/context_url.go @@ -0,0 +1,50 @@ +package urlutil + +import ( + "net/url" + "strings" +) + +// SanitizeContextURL strips credentials and volatile URL fragments before page +// context enters task snapshots, perception signals, or other persisted state. +func SanitizeContextURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + parsed, err := url.Parse(trimmed) + if err != nil { + // Parsing failures must not fall back to the original value because malformed + // URLs can still embed credentials or query text that should never persist. + return "" + } + sanitizeParsedURL(parsed) + return parsed.String() +} + +// sanitizeParsedURL clears volatile components from a parsed URL and recursively +// sanitizes nested URLs that live inside opaque wrapper schemes like view-source. +func sanitizeParsedURL(parsed *url.URL) { + if parsed == nil { + return + } + parsed.User = nil + parsed.RawQuery = "" + parsed.Fragment = "" + if sanitizedOpaque, ok := sanitizeNestedOpaqueURL(parsed.Opaque); ok { + parsed.Opaque = sanitizedOpaque + } +} + +func sanitizeNestedOpaqueURL(raw string) (string, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || !strings.Contains(trimmed, "://") { + return "", false + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" { + return "", false + } + sanitizeParsedURL(parsed) + return parsed.String(), true +} diff --git a/services/local-service/internal/urlutil/context_url_test.go b/services/local-service/internal/urlutil/context_url_test.go new file mode 100644 index 000000000..b4364726e --- /dev/null +++ b/services/local-service/internal/urlutil/context_url_test.go @@ -0,0 +1,45 @@ +package urlutil + +import "testing" + +func TestSanitizeContextURLStripsCredentialsAndVolatileFragments(t *testing.T) { + got := SanitizeContextURL(" https://user:pass@example.com/docs?id=42#intro ") + if got != "https://example.com/docs" { + t.Fatalf("expected sanitized https url, got %q", got) + } +} + +func TestSanitizeContextURLKeepsLocalSchemesStable(t *testing.T) { + got := SanitizeContextURL("local://shell-ball?source=floating_ball#focus") + if got != "local://shell-ball" { + t.Fatalf("expected local scheme to drop query and fragment, got %q", got) + } +} + +func TestSanitizeContextURLSanitizesNestedOpaqueURLs(t *testing.T) { + got := SanitizeContextURL(" view-source:https://user:pass@example.com/docs?id=42#intro ") + if got != "view-source:https://example.com/docs" { + t.Fatalf("expected nested opaque url to be sanitized, got %q", got) + } +} + +func TestSanitizeContextURLRecursivelySanitizesNestedOpaqueWrappers(t *testing.T) { + got := SanitizeContextURL("view-source:jar:https://user:pass@example.com/app.jar!/BOOT-INF/classes?token=1#frag") + if got != "view-source:jar:https://example.com/app.jar!/BOOT-INF/classes" { + t.Fatalf("expected recursive opaque sanitization, got %q", got) + } +} + +func TestSanitizeContextURLLeavesNonURLOpaquePayloadsUntouched(t *testing.T) { + got := SanitizeContextURL("mailto:user:pass@example.com?subject=secret") + if got != "mailto:user:pass@example.com" { + t.Fatalf("expected non-url opaque payload to keep stable shape, got %q", got) + } +} + +func TestSanitizeContextURLDropsMalformedInputsInsteadOfPersistingThemVerbatim(t *testing.T) { + got := SanitizeContextURL(" https://user:pass@example.com/%zz?token=secret ") + if got != "" { + t.Fatalf("expected malformed url to be dropped, got %q", got) + } +}