From 8df9b58918784eac6e2e7abeefd3114c94d87255 Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sat, 9 May 2026 20:09:14 +0800 Subject: [PATCH 01/41] style(local-service): clarify backend go comments --- .../internal/checkpoint/types.go | 23 ++- .../local-service/internal/memory/service.go | 5 +- .../internal/model/extensions.go | 20 +- .../internal/model/openai_responses_client.go | 38 ++-- .../model/openai_responses_client_test.go | 26 +-- .../local-service/internal/model/service.go | 38 ++-- .../internal/model/service_test.go | 14 -- .../local-service/internal/model/types.go | 29 ++- services/local-service/internal/risk/types.go | 24 +-- .../internal/storage/memory_store.go | 20 +- .../internal/storage/memory_store_test.go | 5 - .../storage/sqlite_memory_store_test.go | 6 - .../local-service/internal/tools/registry.go | 72 +++---- .../internal/tools/registry_test.go | 4 +- .../local-service/internal/tools/types.go | 179 ++++++++---------- 15 files changed, 207 insertions(+), 296 deletions(-) diff --git a/services/local-service/internal/checkpoint/types.go b/services/local-service/internal/checkpoint/types.go index 91dd21b01..31ca39951 100644 --- a/services/local-service/internal/checkpoint/types.go +++ b/services/local-service/internal/checkpoint/types.go @@ -2,19 +2,18 @@ package checkpoint import "context" -// CreateInput 是 checkpoint 模块当前最小输入结构。 -// -// 字段语义对齐协议中的 RecoveryPoint, -// 但本类型仅用于后端模块内部,不替代协议真源。 +// CreateInput is the module-local input used to create one recovery point. +// Its fields mirror the formal RecoveryPoint shape without replacing the +// protocol source of truth. type CreateInput struct { TaskID string Summary string Objects []string } -// RecoveryPoint 是 checkpoint 模块当前最小输出结构。 -// -// created_at 使用 RFC3339 字符串,便于后续持久化与协议映射。 +// RecoveryPoint is the module-local recovery point carrier. CreatedAt remains +// an RFC3339 string so storage and protocol mapping can share one timestamp +// representation. type RecoveryPoint struct { RecoveryPointID string TaskID string @@ -23,20 +22,20 @@ type RecoveryPoint struct { Objects []string } -// ApplyResult 描述一次恢复点应用成功后的最小结果。 +// ApplyResult reports the objects restored after a recovery point is applied. type ApplyResult struct { RecoveryPointID string RestoredObjects []string } -// Writer 是恢复点输出边界。 -// -// 当前不绑定数据库实现,后续由 storage 或其他持久化层注入。 +// Writer is the recovery-point persistence boundary injected by bootstrap. The +// checkpoint package does not bind itself to a concrete storage implementation. type Writer interface { WriteRecoveryPoint(ctx context.Context, point RecoveryPoint) error } -// SnapshotFileSystem 是 checkpoint 对工作区快照与恢复所需的最小文件接口。 +// SnapshotFileSystem is the minimal workspace file boundary needed for +// snapshot creation and restoration. type SnapshotFileSystem interface { ReadFile(path string) ([]byte, error) WriteFile(path string, content []byte) error diff --git a/services/local-service/internal/memory/service.go b/services/local-service/internal/memory/service.go index 94f0cc36b..653b9ed74 100644 --- a/services/local-service/internal/memory/service.go +++ b/services/local-service/internal/memory/service.go @@ -1,4 +1,4 @@ -// 该文件负责记忆层接入与检索后端声明。 +// Package memory owns backend memory summary writes and retrieval queries. package memory import ( @@ -51,7 +51,8 @@ func NewServiceFromStorage(store storagesvc.MemoryStore, backend string) *Servic } } -// RetrievalBackend 处理当前模块的相关逻辑。 +// RetrievalBackend returns the configured retrieval backend name, falling back +// to the local SQLite FTS/vector backend when configuration is empty. func (s *Service) RetrievalBackend() string { if strings.TrimSpace(s.backend) == "" { return retrievalBackend diff --git a/services/local-service/internal/model/extensions.go b/services/local-service/internal/model/extensions.go index 05ed2cae0..443dc1596 100644 --- a/services/local-service/internal/model/extensions.go +++ b/services/local-service/internal/model/extensions.go @@ -1,9 +1,10 @@ -// 该文件负责模型接入层的结构或实现。 +// Package model defines provider extension contracts for streaming and tool calls. package model import "context" -// GenerateTextStreamRequest 描述当前模块请求结构。 +// GenerateTextStreamRequest carries one streaming text-generation request. +// TaskID and RunID keep provider output attributable to one task/run pair. type GenerateTextStreamRequest struct { TaskID string RunID string @@ -18,26 +19,27 @@ const ( StreamEventError StreamEventType = "error" ) -// GenerateTextStreamEvent 定义当前模块的数据结构。 +// GenerateTextStreamEvent is one provider stream event. DeltaText is only set +// on delta events; Error is only set on terminal error events. type GenerateTextStreamEvent struct { Type StreamEventType DeltaText string Error string } -// StreamClient 定义当前模块的接口约束。 +// StreamClient is the optional provider boundary for streaming text generation. type StreamClient interface { GenerateTextStream(ctx context.Context, request GenerateTextStreamRequest) (<-chan GenerateTextStreamEvent, error) } -// ToolDefinition 定义当前模块的数据结构。 +// ToolDefinition describes one model-visible tool contract. type ToolDefinition struct { Name string Description string InputSchema map[string]any } -// ToolCallRequest 描述当前模块请求结构。 +// ToolCallRequest asks a provider to choose tools and/or produce text for a run. type ToolCallRequest struct { TaskID string RunID string @@ -45,7 +47,7 @@ type ToolCallRequest struct { Tools []ToolDefinition } -// ToolCallResult 描述当前模块结果结构。 +// ToolCallResult is the normalized provider output after tool-call planning. type ToolCallResult struct { RequestID string Provider string @@ -56,13 +58,13 @@ type ToolCallResult struct { LatencyMS int64 } -// ToolInvocation 定义当前模块的数据结构。 +// ToolInvocation captures one provider-selected tool name and argument payload. type ToolInvocation struct { Name string Arguments map[string]any } -// ToolCallingClient 定义当前模块的接口约束。 +// ToolCallingClient is implemented by providers that return structured tool calls. type ToolCallingClient interface { GenerateToolCalls(ctx context.Context, request ToolCallRequest) (ToolCallResult, error) } diff --git a/services/local-service/internal/model/openai_responses_client.go b/services/local-service/internal/model/openai_responses_client.go index b559c83a8..396d6183f 100644 --- a/services/local-service/internal/model/openai_responses_client.go +++ b/services/local-service/internal/model/openai_responses_client.go @@ -1,4 +1,4 @@ -// 该文件负责 OpenAI Responses provider 的最小实现。 +// Package model contains the OpenAI Responses provider implementation. package model import ( @@ -18,34 +18,35 @@ import ( "github.com/openai/openai-go/responses" ) -// OpenAIResponsesProvider 定义当前模块的基础变量。 +// OpenAIResponsesProvider is the provider identifier exposed by model services. const OpenAIResponsesProvider = "openai_responses" -// ErrOpenAIAPIKeyRequired 定义当前模块的基础变量。 +// ErrOpenAIAPIKeyRequired reports a missing API key before a client is created. var ErrOpenAIAPIKeyRequired = errors.New("openai responses api key is required") -// ErrOpenAIEndpointRequired 定义当前模块的基础变量。 +// ErrOpenAIEndpointRequired reports a missing or empty provider endpoint. var ErrOpenAIEndpointRequired = errors.New("openai responses endpoint is required") -// ErrOpenAIModelIDRequired 定义当前模块的基础变量。 +// ErrOpenAIModelIDRequired reports a missing model identifier. var ErrOpenAIModelIDRequired = errors.New("openai responses model id is required") -// ErrOpenAIRequestFailed 定义当前模块的基础变量。 +// ErrOpenAIRequestFailed wraps transport or SDK errors that are not classified further. var ErrOpenAIRequestFailed = errors.New("openai responses request failed") -// ErrOpenAIRequestTimeout 定义当前模块的基础变量。 +// ErrOpenAIRequestTimeout wraps provider calls that exceeded their deadline. var ErrOpenAIRequestTimeout = errors.New("openai responses request timed out") -// ErrOpenAIResponseInvalid 定义当前模块的基础变量。 +// ErrOpenAIResponseInvalid wraps malformed provider response payloads. var ErrOpenAIResponseInvalid = errors.New("openai responses response invalid") -// ErrOpenAIHTTPStatus 定义当前模块的基础变量。 +// ErrOpenAIHTTPStatus wraps non-success HTTP statuses returned by the provider. var ErrOpenAIHTTPStatus = errors.New("openai responses http status error") -// ErrGenerateTextInputRequired 定义当前模块的基础变量。 +// ErrGenerateTextInputRequired reports an empty generation prompt. var ErrGenerateTextInputRequired = errors.New("generate text input is required") -// OpenAIResponsesClientConfig 描述当前模块配置。 +// OpenAIResponsesClientConfig is the complete dependency/configuration input for +// one OpenAI Responses client. type OpenAIResponsesClientConfig struct { APIKey string Endpoint string @@ -54,7 +55,8 @@ type OpenAIResponsesClientConfig struct { HTTPClient *http.Client } -// OpenAIResponsesClient 封装 OpenAI 官方 Responses SDK。 +// OpenAIResponsesClient wraps the official OpenAI SDK behind the local Client +// and ToolCallingClient contracts. type OpenAIResponsesClient struct { apiKey string endpoint string @@ -64,7 +66,7 @@ type OpenAIResponsesClient struct { client openai.Client } -// OpenAIHTTPStatusError 归一化 SDK 返回的 HTTP 状态错误。 +// OpenAIHTTPStatusError normalizes provider HTTP status failures. type OpenAIHTTPStatusError struct { StatusCode int Message string @@ -83,7 +85,7 @@ func (e *OpenAIHTTPStatusError) Unwrap() error { const defaultOpenAIResponsesTimeout = 30 * time.Second -// NewOpenAIResponsesClient 创建并返回基于官方 SDK 的 client。 +// NewOpenAIResponsesClient validates provider config and returns an SDK-backed client. func NewOpenAIResponsesClient(cfg OpenAIResponsesClientConfig) (*OpenAIResponsesClient, error) { if strings.TrimSpace(cfg.APIKey) == "" { return nil, ErrOpenAIAPIKeyRequired @@ -133,7 +135,7 @@ func NewOpenAIResponsesClient(cfg OpenAIResponsesClientConfig) (*OpenAIResponses }, nil } -// GenerateText 通过官方 Responses SDK 执行最小文本生成。 +// GenerateText performs one minimal text generation through the Responses API. func (c *OpenAIResponsesClient) GenerateText(ctx context.Context, request GenerateTextRequest) (GenerateTextResponse, error) { startedAt := time.Now() if strings.TrimSpace(request.Input) == "" { @@ -212,17 +214,17 @@ func (c *OpenAIResponsesClient) GenerateToolCalls(ctx context.Context, request T }, nil } -// Provider 返回 provider 名称。 +// Provider returns the stable provider identifier used by model descriptors. func (c *OpenAIResponsesClient) Provider() string { return OpenAIResponsesProvider } -// ModelID 返回 model id。 +// ModelID returns the configured model identifier. func (c *OpenAIResponsesClient) ModelID() string { return c.modelID } -// Endpoint 返回配置的原始 endpoint。 +// Endpoint returns the configured raw endpoint before SDK path normalization. func (c *OpenAIResponsesClient) Endpoint() string { return c.endpoint } diff --git a/services/local-service/internal/model/openai_responses_client_test.go b/services/local-service/internal/model/openai_responses_client_test.go index d18643953..15676da2e 100644 --- a/services/local-service/internal/model/openai_responses_client_test.go +++ b/services/local-service/internal/model/openai_responses_client_test.go @@ -1,4 +1,3 @@ -// 该测试文件验证模型接入层行为。 package model import ( @@ -14,7 +13,6 @@ import ( "time" ) -// TestNewOpenAIResponsesClientRequiresAPIKey 验证NewOpenAIResponsesClientRequiresAPIKey。 func TestNewOpenAIResponsesClientRequiresAPIKey(t *testing.T) { _, err := NewOpenAIResponsesClient(OpenAIResponsesClientConfig{ Endpoint: "https://api.openai.com/v1/responses", @@ -25,7 +23,6 @@ func TestNewOpenAIResponsesClientRequiresAPIKey(t *testing.T) { } } -// TestNewOpenAIResponsesClientRequiresEndpoint 验证NewOpenAIResponsesClientRequiresEndpoint。 func TestNewOpenAIResponsesClientRequiresEndpoint(t *testing.T) { _, err := NewOpenAIResponsesClient(OpenAIResponsesClientConfig{ APIKey: "test-key", @@ -36,7 +33,6 @@ func TestNewOpenAIResponsesClientRequiresEndpoint(t *testing.T) { } } -// TestNewOpenAIResponsesClientRequiresModelID 验证NewOpenAIResponsesClientRequiresModelID。 func TestNewOpenAIResponsesClientRequiresModelID(t *testing.T) { _, err := NewOpenAIResponsesClient(OpenAIResponsesClientConfig{ APIKey: "test-key", @@ -47,7 +43,6 @@ func TestNewOpenAIResponsesClientRequiresModelID(t *testing.T) { } } -// TestNewOpenAIResponsesClientUsesProvidedConfig 验证NewOpenAIResponsesClientUsesProvidedConfig。 func TestNewOpenAIResponsesClientUsesProvidedConfig(t *testing.T) { customHTTPClient := &http.Client{} client, err := NewOpenAIResponsesClient(OpenAIResponsesClientConfig{ @@ -82,7 +77,6 @@ func TestNewOpenAIResponsesClientUsesProvidedConfig(t *testing.T) { } } -// TestNewOpenAIResponsesClientUsesDefaultHTTPClient 验证NewOpenAIResponsesClientUsesDefaultHTTPClient。 func TestNewOpenAIResponsesClientUsesDefaultHTTPClient(t *testing.T) { client, err := NewOpenAIResponsesClient(OpenAIResponsesClientConfig{ APIKey: "test-key", @@ -106,7 +100,6 @@ func TestNewOpenAIResponsesClientUsesDefaultHTTPClient(t *testing.T) { } } -// TestNewOpenAIResponsesClientPreservesExistingHTTPClientTimeout 验证NewOpenAIResponsesClientPreservesExistingHTTPClientTimeout。 func TestNewOpenAIResponsesClientPreservesExistingHTTPClientTimeout(t *testing.T) { customHTTPClient := &http.Client{Timeout: 2 * time.Second} client, err := NewOpenAIResponsesClient(OpenAIResponsesClientConfig{ @@ -125,7 +118,6 @@ func TestNewOpenAIResponsesClientPreservesExistingHTTPClientTimeout(t *testing.T } } -// TestGenerateTextSuccess 验证GenerateTextSuccess。 func TestGenerateTextSuccess(t *testing.T) { type capturedRequest struct { Model string `json:"model"` @@ -228,7 +220,6 @@ func TestGenerateTextSuccess(t *testing.T) { } } -// TestGenerateTextFallsBackToOutputContent 验证GenerateTextFallsBackToOutputContent。 func TestGenerateTextFallsBackToOutputContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -438,7 +429,6 @@ func TestGenerateToolCallsFallBackToChatCompletionsWhenResponsesRouteIsMissing(t } } -// TestGenerateTextReturnsHTTPStatusError 验证GenerateTextReturnsHTTPStatusError。 func TestGenerateTextReturnsHTTPStatusError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) @@ -471,7 +461,6 @@ func TestGenerateTextReturnsHTTPStatusError(t *testing.T) { } } -// TestGenerateTextReturnsHTTPStatusErrorForNonJSONBody 验证GenerateTextReturnsHTTPStatusErrorForNonJSONBody。 func TestGenerateTextReturnsHTTPStatusErrorForNonJSONBody(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -508,7 +497,6 @@ func TestGenerateTextReturnsHTTPStatusErrorForNonJSONBody(t *testing.T) { } } -// TestGenerateTextReturnsInvalidResponseError 验证GenerateTextReturnsInvalidResponseError。 func TestGenerateTextReturnsInvalidResponseError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not-json`)) @@ -531,7 +519,6 @@ func TestGenerateTextReturnsInvalidResponseError(t *testing.T) { } } -// TestGenerateTextReturnsTimeoutError 验证GenerateTextReturnsTimeoutError。 func TestGenerateTextReturnsTimeoutError(t *testing.T) { client, err := NewOpenAIResponsesClient(OpenAIResponsesClientConfig{ APIKey: "test-key", @@ -553,7 +540,6 @@ func TestGenerateTextReturnsTimeoutError(t *testing.T) { } } -// TestGenerateTextReturnsRequestError 验证GenerateTextReturnsRequestError。 func TestGenerateTextReturnsRequestError(t *testing.T) { client, err := NewOpenAIResponsesClient(OpenAIResponsesClientConfig{ APIKey: "test-key", @@ -582,7 +568,6 @@ func TestOpenAIHTTPStatusErrorUsesFallbackMessageWhenMessageMissing(t *testing.T } } -// TestGenerateTextRejectsEmptyInput 验证GenerateTextRejectsEmptyInput。 func TestGenerateTextRejectsEmptyInput(t *testing.T) { client, err := NewOpenAIResponsesClient(OpenAIResponsesClientConfig{ APIKey: "test-key", @@ -601,31 +586,28 @@ func TestGenerateTextRejectsEmptyInput(t *testing.T) { type roundTripFunc func(*http.Request) (*http.Response, error) -// RoundTrip 处理当前模块的相关逻辑。 +// RoundTrip adapts a function literal to http.RoundTripper for tests. func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } -// timeoutError 定义当前模块的数据结构。 type timeoutError struct{} -// Error 处理当前模块的相关逻辑。 +// Error returns the timeout test error message. func (timeoutError) Error() string { return "timeout" } -// Timeout 处理当前模块的相关逻辑。 +// Timeout reports that this test error represents a timeout. func (timeoutError) Timeout() bool { return true } -// Temporary 处理当前模块的相关逻辑。 +// Temporary reports that this test error is not temporary. func (timeoutError) Temporary() bool { return false } -// _ 定义当前模块的基础变量。 var _ net.Error = timeoutError{} -// _ 定义当前模块的基础变量。 var _ = time.Second diff --git a/services/local-service/internal/model/service.go b/services/local-service/internal/model/service.go index b1c9c324a..3d935925d 100644 --- a/services/local-service/internal/model/service.go +++ b/services/local-service/internal/model/service.go @@ -1,4 +1,4 @@ -// 该文件负责模型接入层的结构或实现。 +// Package model coordinates configured provider clients for local-service runs. package model import ( @@ -9,7 +9,8 @@ import ( "github.com/cialloclaw/cialloclaw/services/local-service/internal/config" ) -// Service 提供当前模块的服务能力。 +// Service keeps the selected provider, model configuration, and provider client +// behind the backend model boundary. type Service struct { provider string modelID string @@ -22,7 +23,7 @@ type Service struct { contextKeepRecent int } -// ErrClientNotConfigured 定义当前模块的基础变量。 +// ErrClientNotConfigured reports that a model service has no provider client. var ErrClientNotConfigured = errors.New("model client not configured") var ErrToolCallingNotSupported = errors.New("model client does not support tool calling") @@ -32,21 +33,19 @@ const ( defaultContextKeepRecent = 4 ) -// ErrModelProviderRequired 定义当前模块的基础变量。 +// ErrModelProviderRequired reports a missing provider name in model config. var ErrModelProviderRequired = errors.New("model provider is required") -// ErrModelProviderUnsupported 定义当前模块的基础变量。 +// ErrModelProviderUnsupported reports a provider name without a registered adapter. var ErrModelProviderUnsupported = errors.New("model provider unsupported") -// ErrSecretSourceFailed 定义当前模块的基础变量。 +// ErrSecretSourceFailed wraps failures while resolving provider credentials. var ErrSecretSourceFailed = errors.New("model secret source failed") // ErrSecretNotFound reports that no secret could be resolved for the requested provider. var ErrSecretNotFound = errors.New("model secret not found") -// SecretSource 是面向 Stronghold 等机密存储能力的最小接口。 -// -// 当前阶段只定义边界,不绑定具体实现。 +// SecretSource is the minimal credential-resolution boundary for model providers. type SecretSource interface { ResolveModelAPIKey(provider string) (string, error) } @@ -74,14 +73,15 @@ func (s *StaticSecretSource) ResolveModelAPIKey(provider string) (string, error) return s.store.ResolveModelAPIKey(strings.TrimSpace(provider)) } -// ServiceConfig 描述当前模块配置。 +// ServiceConfig combines provider config, explicit credentials, and optional +// secret resolution. type ServiceConfig struct { ModelConfig config.ModelConfig APIKey string SecretSource SecretSource } -// NewService 创建并返回Service。 +// NewService returns a model service around the supplied config and optional client. func NewService(cfg config.ModelConfig, clients ...Client) *Service { var client Client if len(clients) > 0 { @@ -101,7 +101,7 @@ func NewService(cfg config.ModelConfig, clients ...Client) *Service { } } -// NewServiceFromConfig 创建并返回ServiceFromConfig。 +// NewServiceFromConfig validates config, resolves credentials, and builds a client. func NewServiceFromConfig(cfg ServiceConfig) (*Service, error) { if err := ValidateModelConfig(cfg.ModelConfig); err != nil { return nil, err @@ -115,22 +115,22 @@ func NewServiceFromConfig(cfg ServiceConfig) (*Service, error) { return NewService(cfg.ModelConfig, client), nil } -// Descriptor 处理当前模块的相关逻辑。 +// Descriptor returns the provider:model identifier used in diagnostics. func (s *Service) Descriptor() string { return s.provider + ":" + s.modelID } -// Provider 处理当前模块的相关逻辑。 +// Provider returns the configured provider identifier. func (s *Service) Provider() string { return s.provider } -// ModelID 处理当前模块的相关逻辑。 +// ModelID returns the configured model identifier. func (s *Service) ModelID() string { return s.modelID } -// Endpoint 处理当前模块的相关逻辑。 +// Endpoint returns the configured provider endpoint. func (s *Service) Endpoint() string { return s.endpoint } @@ -179,7 +179,7 @@ func (s *Service) ToolRetryBudget() int { return 1 } -// GenerateText 处理当前模块的相关逻辑。 +// GenerateText delegates one generation request to the configured provider client. func (s *Service) GenerateText(ctx context.Context, request GenerateTextRequest) (GenerateTextResponse, error) { if s.client == nil { return GenerateTextResponse{}, ErrClientNotConfigured @@ -208,7 +208,7 @@ func (s *Service) GenerateToolCalls(ctx context.Context, request ToolCallRequest return client.GenerateToolCalls(ctx, request) } -// ValidateModelConfig 处理当前模块的相关逻辑。 +// ValidateModelConfig enforces the minimal provider fields required to build a client. func ValidateModelConfig(cfg config.ModelConfig) error { provider := strings.TrimSpace(cfg.Provider) endpoint := strings.TrimSpace(cfg.Endpoint) @@ -220,7 +220,7 @@ func ValidateModelConfig(cfg config.ModelConfig) error { return validateProviderConfig(config.ModelConfig{Provider: provider, Endpoint: endpoint, ModelID: modelID}) } -// buildClient 处理当前模块的相关逻辑。 +// buildClient resolves credentials and constructs the configured provider client. func buildClient(cfg ServiceConfig) (Client, error) { apiKey := strings.TrimSpace(cfg.APIKey) if apiKey == "" && cfg.SecretSource != nil { diff --git a/services/local-service/internal/model/service_test.go b/services/local-service/internal/model/service_test.go index 8ca40a1e7..3228f9404 100644 --- a/services/local-service/internal/model/service_test.go +++ b/services/local-service/internal/model/service_test.go @@ -1,4 +1,3 @@ -// 该测试文件验证模型接入层行为。 package model import ( @@ -12,7 +11,6 @@ import ( "github.com/cialloclaw/cialloclaw/services/local-service/internal/config" ) -// mockClient 定义当前模块的数据结构。 type mockClient struct { response GenerateTextResponse err error @@ -57,7 +55,6 @@ func (s stubSecretStore) ResolveModelAPIKey(provider string) (string, error) { return s.apiKey, nil } -// GenerateText 处理当前模块的相关逻辑。 func (m *mockClient) GenerateText(_ context.Context, request GenerateTextRequest) (GenerateTextResponse, error) { m.called = true m.request = request @@ -81,7 +78,6 @@ func (m *mockToolCallingClient) GenerateToolCalls(_ context.Context, request Too return m.response, nil } -// TestNewServiceStoresConfig 验证NewServiceStoresConfig。 func TestNewServiceStoresConfig(t *testing.T) { cfg := config.ModelConfig{ Provider: "openai_responses", @@ -150,7 +146,6 @@ func TestNewServicePreservesConfiguredRetryBudgets(t *testing.T) { } } -// TestGenerateTextReturnsErrorWhenClientMissing 验证GenerateTextReturnsErrorWhenClientMissing。 func TestGenerateTextReturnsErrorWhenClientMissing(t *testing.T) { service := NewService(config.ModelConfig{}, nil) @@ -160,7 +155,6 @@ func TestGenerateTextReturnsErrorWhenClientMissing(t *testing.T) { } } -// TestGenerateTextDelegatesToClient 验证GenerateTextDelegatesToClient。 func TestGenerateTextDelegatesToClient(t *testing.T) { client := &mockClient{ response: GenerateTextResponse{ @@ -237,7 +231,6 @@ func TestGenerateToolCallsDelegatesToToolCallingClient(t *testing.T) { } } -// TestValidateModelConfigRequiresProvider 验证ValidateModelConfigRequiresProvider。 func TestValidateModelConfigRequiresProvider(t *testing.T) { err := ValidateModelConfig(config.ModelConfig{}) if !errors.Is(err, ErrModelProviderRequired) { @@ -245,7 +238,6 @@ func TestValidateModelConfigRequiresProvider(t *testing.T) { } } -// TestValidateModelConfigRejectsUnsupportedProvider 验证ValidateModelConfigRejectsUnsupportedProvider。 func TestValidateModelConfigRejectsUnsupportedProvider(t *testing.T) { err := ValidateModelConfig(config.ModelConfig{Provider: "unknown"}) if !errors.Is(err, ErrModelProviderUnsupported) { @@ -301,7 +293,6 @@ func TestBuildProviderClientHandlesUnsupportedAndBuilderErrors(t *testing.T) { } } -// TestValidateModelConfigTrimsWhitespace 验证ValidateModelConfigTrimsWhitespace。 func TestValidateModelConfigTrimsWhitespace(t *testing.T) { err := ValidateModelConfig(config.ModelConfig{ Provider: " openai_responses ", @@ -313,7 +304,6 @@ func TestValidateModelConfigTrimsWhitespace(t *testing.T) { } } -// TestNewServiceFromConfigBuildsOpenAIClient 验证NewServiceFromConfigBuildsOpenAIClient。 func TestNewServiceFromConfigBuildsOpenAIClient(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -346,7 +336,6 @@ func TestNewServiceFromConfigBuildsOpenAIClient(t *testing.T) { } } -// TestNewServiceFromConfigUsesServiceConfigAPIKey 验证NewServiceFromConfigUsesServiceConfigAPIKey。 func TestNewServiceFromConfigUsesServiceConfigAPIKey(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer model-config-key" { @@ -377,7 +366,6 @@ func TestNewServiceFromConfigUsesServiceConfigAPIKey(t *testing.T) { } } -// TestGenerateTextResponseInvocationRecord 验证GenerateTextResponseInvocationRecord。 func TestGenerateTextResponseInvocationRecord(t *testing.T) { response := GenerateTextResponse{ TaskID: "task_001", @@ -408,7 +396,6 @@ func TestGenerateTextResponseInvocationRecord(t *testing.T) { } } -// TestNewServiceFromConfigUsesSecretSourceAPIKey 验证NewServiceFromConfigUsesSecretSourceAPIKey。 func TestNewServiceFromConfigUsesSecretSourceAPIKey(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer secret-source-key" { @@ -439,7 +426,6 @@ func TestNewServiceFromConfigUsesSecretSourceAPIKey(t *testing.T) { } } -// TestNewServiceFromConfigReturnsSecretSourceError 验证NewServiceFromConfigReturnsSecretSourceError。 func TestNewServiceFromConfigReturnsSecretSourceError(t *testing.T) { _, err := NewServiceFromConfig(ServiceConfig{ ModelConfig: config.ModelConfig{ diff --git a/services/local-service/internal/model/types.go b/services/local-service/internal/model/types.go index 5c9188b06..94dffbe78 100644 --- a/services/local-service/internal/model/types.go +++ b/services/local-service/internal/model/types.go @@ -1,32 +1,27 @@ -// 该文件定义 model 模块内部当前使用的最小请求/响应结构。 +// Package model defines backend model request and response carriers. // -// 注意:这些 Go 结构当前是 `/packages/protocol/types/core.ts` 中对应模型结构的 -// 后端镜像,用于在未引入跨语言代码生成前保持字段命名和语义对齐。 +// These Go structs mirror the corresponding shapes in +// /packages/protocol/types/core.ts until cross-language code generation owns the +// boundary. package model import "context" -// GenerateTextRequest 描述最小文本生成请求。 -// -// 字段与 protocol 中的 `ModelGenerateTextRequest` 对齐。 +// GenerateTextRequest is the minimal backend mirror of ModelGenerateTextRequest. type GenerateTextRequest struct { TaskID string `json:"task_id"` RunID string `json:"run_id"` Input string `json:"input"` } -// TokenUsage 描述最小 token 使用结构。 -// -// 字段与 protocol 中的 `ModelTokenUsage` 对齐。 +// TokenUsage mirrors ModelTokenUsage for provider accounting. type TokenUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` TotalTokens int `json:"total_tokens"` } -// InvocationRecord 描述最小模型调用记录。 -// -// 字段与 protocol 中的 `ModelInvocationRecord` 对齐。 +// InvocationRecord mirrors ModelInvocationRecord for audit and event payloads. type InvocationRecord struct { TaskID string `json:"task_id"` RunID string `json:"run_id"` @@ -37,7 +32,7 @@ type InvocationRecord struct { LatencyMS int64 `json:"latency_ms"` } -// Map 将最小调用记录转换为便于上层消费的结构化 map。 +// Map returns a protocol-friendly map while preserving serialized field names. func (r InvocationRecord) Map() map[string]any { return map[string]any{ "task_id": r.TaskID, @@ -54,9 +49,7 @@ func (r InvocationRecord) Map() map[string]any { } } -// GenerateTextResponse 描述最小文本生成返回。 -// -// 字段与 protocol 中的 `ModelGenerateTextResponse` 对齐。 +// GenerateTextResponse mirrors ModelGenerateTextResponse for backend callers. type GenerateTextResponse struct { TaskID string `json:"task_id"` RunID string `json:"run_id"` @@ -68,7 +61,7 @@ type GenerateTextResponse struct { LatencyMS int64 `json:"latency_ms"` } -// InvocationRecord 将 GenerateTextResponse 转换为最小调用记录。 +// InvocationRecord converts the response into the minimal model-call record. func (r GenerateTextResponse) InvocationRecord() InvocationRecord { return InvocationRecord{ TaskID: r.TaskID, @@ -81,7 +74,7 @@ func (r GenerateTextResponse) InvocationRecord() InvocationRecord { } } -// Client 定义 model provider 的最小接口约束。 +// Client is the minimal text-generation provider boundary. type Client interface { GenerateText(ctx context.Context, request GenerateTextRequest) (GenerateTextResponse, error) } diff --git a/services/local-service/internal/risk/types.go b/services/local-service/internal/risk/types.go index 9fb400077..51a021deb 100644 --- a/services/local-service/internal/risk/types.go +++ b/services/local-service/internal/risk/types.go @@ -1,8 +1,7 @@ package risk -// RiskLevel 定义风险等级。 -// -// 取值严格对齐已冻结枚举:green / yellow / red。 +// RiskLevel is the canonical risk level used by backend risk decisions. +// Values must stay aligned with the frozen green/yellow/red protocol semantics. type RiskLevel string const ( @@ -22,10 +21,8 @@ const ( ReasonNormal = "normal" ) -// ImpactScope 是 risk 模块内部使用的最小影响面结构。 -// -// 字段语义对齐 protocol 中已定义的 ImpactScope, -// 但本类型只在后端 risk 模块内部使用,不直接替代协议真源。 +// ImpactScope is the module-local impact shape used before protocol mapping. +// It mirrors the protocol ImpactScope fields without replacing the source of truth. type ImpactScope struct { Files []string Webpages []string @@ -34,9 +31,8 @@ type ImpactScope struct { OverwriteOrDeleteRisk bool } -// AssessmentInput 描述一次风险评估的最小输入。 -// -// 本阶段只保留 risk 模块真正需要的判断信息,避免侵入主链路状态机。 +// AssessmentInput is the minimal data needed for one risk evaluation. +// It avoids pulling task state-machine details into the risk package. type AssessmentInput struct { OperationName string TargetObject string @@ -46,11 +42,9 @@ type AssessmentInput struct { ImpactScope ImpactScope } -// AssessmentResult 描述一次风险评估的最小结果。 -// -// approval_required 表示应交由上层进入人工确认流; -// deny 表示当前模块建议直接拦截,不进入执行; -// reason 用于上层构造统一错误或审批原因,但不直接等同完整协议对象。 +// AssessmentResult is the minimal risk decision returned to orchestrator. +// ApprovalRequired asks the caller to enter authorization, Deny blocks execution, +// and Reason stays a local explanation rather than a full protocol object. type AssessmentResult struct { RiskLevel RiskLevel ApprovalRequired bool diff --git a/services/local-service/internal/storage/memory_store.go b/services/local-service/internal/storage/memory_store.go index 998b669b8..156a95236 100644 --- a/services/local-service/internal/storage/memory_store.go +++ b/services/local-service/internal/storage/memory_store.go @@ -1,4 +1,4 @@ -// 该文件负责存储层的数据接口或落盘实现。 +// Package storage contains process-local and SQLite-backed persistence stores. package storage import ( @@ -8,17 +8,17 @@ import ( "sync" ) -// defaultMemoryListLimit 定义当前模块的基础变量。 +// defaultMemoryListLimit is the fallback result count for memory reads. const defaultMemoryListLimit = 5 -// InMemoryMemoryStore 定义当前模块的数据结构。 +// InMemoryMemoryStore is the process-local memory persistence fallback. type InMemoryMemoryStore struct { mu sync.RWMutex summaries []MemorySummaryRecord retrievalHits []MemoryRetrievalRecord } -// NewInMemoryMemoryStore 创建并返回InMemoryMemoryStore。 +// NewInMemoryMemoryStore returns an empty in-memory store. func NewInMemoryMemoryStore() *InMemoryMemoryStore { return &InMemoryMemoryStore{ summaries: make([]MemorySummaryRecord, 0), @@ -26,7 +26,7 @@ func NewInMemoryMemoryStore() *InMemoryMemoryStore { } } -// SaveSummary 处理当前模块的相关逻辑。 +// SaveSummary appends one validated summary to the in-memory store. func (s *InMemoryMemoryStore) SaveSummary(_ context.Context, summary MemorySummaryRecord) error { if err := validateMemorySummaryRecord(summary); err != nil { return err @@ -39,7 +39,7 @@ func (s *InMemoryMemoryStore) SaveSummary(_ context.Context, summary MemorySumma return nil } -// SaveRetrievalHits 处理当前模块的相关逻辑。 +// SaveRetrievalHits appends validated retrieval-hit records. func (s *InMemoryMemoryStore) SaveRetrievalHits(_ context.Context, hits []MemoryRetrievalRecord) error { if len(hits) == 0 { return nil @@ -58,7 +58,7 @@ func (s *InMemoryMemoryStore) SaveRetrievalHits(_ context.Context, hits []Memory return nil } -// SearchSummaries 处理当前模块的相关逻辑。 +// SearchSummaries returns ranked summaries from other task/run pairs. func (s *InMemoryMemoryStore) SearchSummaries(_ context.Context, taskID, runID, query string, limit int) ([]MemoryRetrievalRecord, error) { limit = normalizeMemoryLimit(limit) query = strings.ToLower(strings.TrimSpace(query)) @@ -106,7 +106,7 @@ func (s *InMemoryMemoryStore) SearchSummaries(_ context.Context, taskID, runID, return hits, nil } -// ListRecentSummaries 列出RecentSummaries。 +// ListRecentSummaries returns newest summaries first. func (s *InMemoryMemoryStore) ListRecentSummaries(_ context.Context, limit int) ([]MemorySummaryRecord, error) { limit = normalizeMemoryLimit(limit) @@ -125,7 +125,7 @@ func (s *InMemoryMemoryStore) ListRecentSummaries(_ context.Context, limit int) return result, nil } -// normalizeMemoryLimit 处理当前模块的相关逻辑。 +// normalizeMemoryLimit applies the default list limit for non-positive requests. func normalizeMemoryLimit(limit int) int { if limit <= 0 { return defaultMemoryListLimit @@ -134,7 +134,7 @@ func normalizeMemoryLimit(limit int) int { return limit } -// matchMemorySummary 处理当前模块的相关逻辑。 +// matchMemorySummary scores query-term overlap against one summary. func matchMemorySummary(summary, query string) float64 { summary = strings.ToLower(strings.TrimSpace(summary)) if summary == "" || query == "" { diff --git a/services/local-service/internal/storage/memory_store_test.go b/services/local-service/internal/storage/memory_store_test.go index a333e41c5..78127c34c 100644 --- a/services/local-service/internal/storage/memory_store_test.go +++ b/services/local-service/internal/storage/memory_store_test.go @@ -1,4 +1,3 @@ -// 该测试文件验证存储层的数据行为。 package storage import ( @@ -6,7 +5,6 @@ import ( "testing" ) -// TestInMemoryMemoryStoreSearchReturnsRankedMatches 验证InMemoryMemoryStoreSearchReturnsRankedMatches。 func TestInMemoryMemoryStoreSearchReturnsRankedMatches(t *testing.T) { store := NewInMemoryMemoryStore() seed := []MemorySummaryRecord{ @@ -34,7 +32,6 @@ func TestInMemoryMemoryStoreSearchReturnsRankedMatches(t *testing.T) { } } -// TestInMemoryMemoryStoreListRecentSummariesReturnsLatestFirst 验证InMemoryMemoryStoreListRecentSummariesReturnsLatestFirst。 func TestInMemoryMemoryStoreListRecentSummariesReturnsLatestFirst(t *testing.T) { store := NewInMemoryMemoryStore() seed := []MemorySummaryRecord{ @@ -62,7 +59,6 @@ func TestInMemoryMemoryStoreListRecentSummariesReturnsLatestFirst(t *testing.T) } } -// TestInMemoryMemoryStoreUsesDefaultLimitWhenNonPositive 验证InMemoryMemoryStoreUsesDefaultLimitWhenNonPositive。 func TestInMemoryMemoryStoreUsesDefaultLimitWhenNonPositive(t *testing.T) { store := NewInMemoryMemoryStore() for i := 0; i < 6; i++ { @@ -87,7 +83,6 @@ func TestInMemoryMemoryStoreUsesDefaultLimitWhenNonPositive(t *testing.T) { } } -// TestInMemoryMemoryStoreSaveRetrievalHitsStoresRecords 验证InMemoryMemoryStoreSaveRetrievalHitsStoresRecords。 func TestInMemoryMemoryStoreSaveRetrievalHitsStoresRecords(t *testing.T) { store := NewInMemoryMemoryStore() diff --git a/services/local-service/internal/storage/sqlite_memory_store_test.go b/services/local-service/internal/storage/sqlite_memory_store_test.go index 69dabbdfe..56479ce27 100644 --- a/services/local-service/internal/storage/sqlite_memory_store_test.go +++ b/services/local-service/internal/storage/sqlite_memory_store_test.go @@ -1,4 +1,3 @@ -// 该测试文件验证存储层的数据行为。 package storage import ( @@ -10,7 +9,6 @@ import ( "time" ) -// TestNewSQLiteMemoryStoreInitializesWALMode 验证NewSQLiteMemoryStoreInitializesWALMode。 func TestNewSQLiteMemoryStoreInitializesWALMode(t *testing.T) { path := filepath.Join(t.TempDir(), "memory.db") store, err := NewSQLiteMemoryStore(path) @@ -32,7 +30,6 @@ func TestNewSQLiteMemoryStoreInitializesWALMode(t *testing.T) { assertTableExists(t, store.db, sqliteVectorStubTableName) } -// TestSQLiteMemoryStoreSaveSearchAndListRecent 验证SQLiteMemoryStoreSaveSearchAndListRecent。 func TestSQLiteMemoryStoreSaveSearchAndListRecent(t *testing.T) { path := filepath.Join(t.TempDir(), "memory.db") store, err := NewSQLiteMemoryStore(path) @@ -92,7 +89,6 @@ func TestSQLiteMemoryStoreSaveSearchAndListRecent(t *testing.T) { assertRetrievalHitCount(t, store.db, 1) } -// TestNewServicePrefersSQLiteMemoryStoreWhenConfigured 验证NewServicePrefersSQLiteMemoryStoreWhenConfigured。 func TestNewServicePrefersSQLiteMemoryStoreWhenConfigured(t *testing.T) { path := filepath.Join(t.TempDir(), "service.db") service := NewService(stubAdapter{databasePath: path}) @@ -112,7 +108,6 @@ func TestNewServicePrefersSQLiteMemoryStoreWhenConfigured(t *testing.T) { } } -// TestSQLiteMemoryStoreRejectsInvalidSummaryRecord 验证SQLiteMemoryStoreRejectsInvalidSummaryRecord。 func TestSQLiteMemoryStoreRejectsInvalidSummaryRecord(t *testing.T) { path := filepath.Join(t.TempDir(), "invalid.db") store, err := NewSQLiteMemoryStore(path) @@ -133,7 +128,6 @@ func TestSQLiteMemoryStoreRejectsInvalidSummaryRecord(t *testing.T) { } } -// TestSQLiteMemoryStoreRejectsInvalidRetrievalHitRecord 验证SQLiteMemoryStoreRejectsInvalidRetrievalHitRecord。 func TestSQLiteMemoryStoreRejectsInvalidRetrievalHitRecord(t *testing.T) { path := filepath.Join(t.TempDir(), "invalid-hit.db") store, err := NewSQLiteMemoryStore(path) diff --git a/services/local-service/internal/tools/registry.go b/services/local-service/internal/tools/registry.go index c0a02a499..b4b51e133 100644 --- a/services/local-service/internal/tools/registry.go +++ b/services/local-service/internal/tools/registry.go @@ -1,13 +1,14 @@ -// Package tools 提供工具注册中心。 +// Package tools provides the process-local tool registry. // -// ToolRegistry 是 tools 模块的统一注册中心,用于: -// - 注册工具 -// - 按名称查找工具 -// - 列出全部工具元数据 -// - 按来源筛选工具 +// ToolRegistry is the shared registry for: +// - registering tools +// - looking up tools by name +// - listing tool metadata +// - filtering tools by source // -// 该注册中心不负责插件市场、动态热加载或远程发现, -// 只负责进程内最小可用的注册与查找能力。 +// It intentionally excludes plugin marketplace lookup, dynamic reloads, and +// remote discovery. The registry only owns the minimal in-process registration +// and lookup boundary. package tools import ( @@ -16,28 +17,27 @@ import ( "sync" ) -// ToolRegistry 是 P0 阶段的统一工具注册中心。 +// ToolRegistry stores process-local tool registrations. // -// 它维护 name -> Tool 的内存映射,保证: -// - tool name 全局唯一 -// - 重复注册被拒绝 -// - 所有注册项都通过 ToolMetadata.Validate 校验 -// - 查找不存在工具时返回统一错误 +// It maintains a name-to-tool map and guarantees that: +// - tool names are globally unique within the process +// - duplicate registration is rejected +// - each tool passes ToolMetadata.Validate before registration +// - missing lookups return the shared not-found error type ToolRegistry struct { mu sync.RWMutex tools map[string]Tool } -// Registry 是 ToolRegistry 的兼容别名。 -// -// 仓库内已有模块使用 `*tools.Registry`,这里保留别名, -// 避免在引入 ToolRegistry 的同时扩大跨模块改动范围。 +// Registry is the existing public alias for ToolRegistry. +// Keeping it avoids expanding call-site churn while ToolRegistry remains the +// implementation type. type Registry = ToolRegistry -// NewRegistry 创建并返回一个空的工具注册中心。 +// NewRegistry returns an empty registry and optionally registers initial tools. // -// 可选传入若干工具,创建时会立即注册;若注册失败则 panic, -// 这样可以在 bootstrap 阶段尽早暴露非法工具定义。 +// Initial registration panics on invalid tools so bootstrap can fail fast on +// impossible static configuration. func NewRegistry(initialTools ...Tool) *Registry { registry := &ToolRegistry{ tools: make(map[string]Tool), @@ -48,12 +48,10 @@ func NewRegistry(initialTools ...Tool) *Registry { return registry } -// Register 将一个工具注册到注册中心。 +// Register validates and stores one tool. // -// 约束: -// - tool 不能为空 -// - tool.Metadata() 必须合法 -// - name 必须唯一 +// The tool must be non-nil, its metadata must be valid, and its name must be +// unique in the registry. func (r *ToolRegistry) Register(tool Tool) error { if tool == nil { return fmt.Errorf("%w: nil tool", ErrToolValidationFailed) @@ -75,18 +73,14 @@ func (r *ToolRegistry) Register(tool Tool) error { return nil } -// MustRegister 注册工具;若失败则直接 panic。 -// -// 适用于启动期静态注册,避免上层反复处理不可能恢复的配置错误。 +// MustRegister registers one static bootstrap tool and panics on failure. func (r *ToolRegistry) MustRegister(tool Tool) { if err := r.Register(tool); err != nil { panic(err) } } -// Get 按名称查找工具。 -// -// 如果工具不存在,返回 ErrToolNotFound。 +// Get returns the tool registered under name or ErrToolNotFound. func (r *ToolRegistry) Get(name string) (Tool, error) { r.mu.RLock() defer r.mu.RUnlock() @@ -99,9 +93,7 @@ func (r *ToolRegistry) Get(name string) (Tool, error) { return tool, nil } -// List 返回当前已注册工具的元数据列表。 -// -// 返回结果按 name 升序排序,便于测试与上层稳定消费。 +// List returns registered tool metadata sorted by name for stable callers. func (r *ToolRegistry) List() []ToolMetadata { r.mu.RLock() defer r.mu.RUnlock() @@ -118,9 +110,7 @@ func (r *ToolRegistry) List() []ToolMetadata { return items } -// ListBySource 返回指定来源的工具元数据列表。 -// -// 返回结果按 name 升序排序。 +// ListBySource returns registered tool metadata for one source sorted by name. func (r *ToolRegistry) ListBySource(source ToolSource) []ToolMetadata { r.mu.RLock() defer r.mu.RUnlock() @@ -140,9 +130,7 @@ func (r *ToolRegistry) ListBySource(source ToolSource) []ToolMetadata { return items } -// Names 返回当前已注册工具名称列表。 -// -// 这是对现有 orchestrator 使用方式的兼容辅助方法。 +// Names returns registered tool names for existing orchestrator call sites. func (r *ToolRegistry) Names() []string { items := r.List() names := make([]string, 0, len(items)) @@ -152,7 +140,7 @@ func (r *ToolRegistry) Names() []string { return names } -// Count 返回当前已注册工具数量。 +// Count returns the number of registered tools. func (r *ToolRegistry) Count() int { r.mu.RLock() defer r.mu.RUnlock() diff --git a/services/local-service/internal/tools/registry_test.go b/services/local-service/internal/tools/registry_test.go index bf2956383..983152888 100644 --- a/services/local-service/internal/tools/registry_test.go +++ b/services/local-service/internal/tools/registry_test.go @@ -225,7 +225,7 @@ func TestNewRegistryPanicsOnBadInitialTool(t *testing.T) { func ExampleRegistry() { reg := NewRegistry() - reg.MustRegister(makeRegistryTool("read_file", "读取文件", ToolSourceBuiltin)) + reg.MustRegister(makeRegistryTool("read_file", "Read file", ToolSourceBuiltin)) reg.MustRegister(makeRegistryTool("ocr_scan", "OCR扫描", ToolSourceWorker)) tool, err := reg.Get("read_file") @@ -240,7 +240,7 @@ func ExampleRegistry() { fmt.Println(len(builtins)) // Output: - // 读取文件 + // Read file // 2 // 1 } diff --git a/services/local-service/internal/tools/types.go b/services/local-service/internal/tools/types.go index 0deb8ca72..6dc2e2c92 100644 --- a/services/local-service/internal/tools/types.go +++ b/services/local-service/internal/tools/types.go @@ -1,12 +1,13 @@ -// Package tools 定义 CialloClaw 后端工具能力接入层的核心类型与接口。 +// Package tools defines backend tool capability boundaries and shared carriers. // -// 本模块只负责 tool registry、tool adapter、tool executor facade、 -// builtin tool 和 worker/sidecar client 接入,不负责 intent 识别、 -// orchestrator/runengine 状态机、delivery_result 编排或前端协议消费。 +// This package owns the tool registry, adapters, executor facade, built-in +// tools, and worker/sidecar client boundaries. It does not own intent +// recognition, orchestrator/runengine state machines, delivery_result assembly, +// or frontend protocol consumption. // -// 所有 tool 名称使用 snake_case,所有输出结构服从 /packages/protocol, -// 所有工具执行都必须产生 ToolCall 记录,平台相关逻辑必须通过 -// platform adapter 注入。 +// Tool names use snake_case, outputs must remain mappable to /packages/protocol, +// each execution must produce a ToolCall record, and platform behavior must be +// injected through a platform adapter. package tools import ( @@ -20,36 +21,33 @@ import ( ) // --------------------------------------------------------------------------- -// ToolSource:工具来源分类 +// Tool source classification. // --------------------------------------------------------------------------- -// ToolSource 表示工具的来源类型,用于 registry 和 executor 区分工具能力层级。 +// ToolSource classifies the capability layer used by registry and executor code. type ToolSource string const ( - // ToolSourceBuiltin 表示本地内置工具,进程内直接执行。 + // ToolSourceBuiltin marks local built-in tools executed in-process. ToolSourceBuiltin ToolSource = "builtin" - // ToolSourceWorker 表示通过独立 worker 进程调用的外部工具。 + // ToolSourceWorker marks tools invoked through a worker process. ToolSourceWorker ToolSource = "worker" - // ToolSourceSidecar 表示通过 sidecar 进程调用的外部工具。 + // ToolSourceSidecar marks tools invoked through a sidecar process. ToolSourceSidecar ToolSource = "sidecar" ) // --------------------------------------------------------------------------- -// ToolMetadata:工具元数据 +// Tool metadata. // --------------------------------------------------------------------------- -// ToolMetadata 描述一个已注册工具的静态元信息。 +// ToolMetadata describes static metadata for one registered tool. // -// name 必须使用 snake_case,全局唯一,与 ToolCall.tool_name 对齐。 -// display_name 是面向展示的可读名称,不作为注册键。 -// description 用于工具发现与推荐场景的简短说明。 -// source 标识工具来源:builtin / worker / sidecar。 -// risk_hint 提示该工具的风险等级,与统一状态 risk_level 对齐。 -// timeout_sec 表示单次执行的超时秒数,0 表示由 executor 默认值接管。 -// input_schema_ref 和 output_schema_ref 引用 /packages/protocol 中的 -// schema 定义路径,本模块不自行解析 schema,只保留引用。 -// supports_dry_run 表示该工具是否支持预检查(dry run)模式。 +// name must be snake_case, globally unique, and aligned with ToolCall.tool_name. +// display_name is a user-facing label, not the registry key. description is a +// short discovery hint. source marks builtin/worker/sidecar ownership. risk_hint +// aligns with shared risk_level semantics. timeout_sec uses executor defaults +// when zero. input_schema_ref and output_schema_ref reference /packages/protocol +// schemas without parsing them here. supports_dry_run advertises precheck support. type ToolMetadata struct { Name string `json:"name"` DisplayName string `json:"display_name"` @@ -62,7 +60,7 @@ type ToolMetadata struct { SupportsDryRun bool `json:"supports_dry_run"` } -// Validate 校验 ToolMetadata 的必填字段与命名规范。 +// Validate enforces required metadata fields and tool-name format. func (m ToolMetadata) Validate() error { if m.Name == "" { return ErrToolNameRequired @@ -83,7 +81,7 @@ func (m ToolMetadata) Validate() error { } // --------------------------------------------------------------------------- -// ToolResult:工具执行结果 +// Tool execution results. // --------------------------------------------------------------------------- // ToolResult is normalized tool output before executor lifecycle recording. @@ -141,11 +139,9 @@ type ToolCallSink interface { SaveToolCall(ctx context.Context, record ToolCallRecord) error } -// ArtifactRef 描述工具执行产生的产物引用。 -// -// 本类型不替代 /packages/protocol 中的 Artifact 定义, -// 只在 tools 模块内部用于向上层传递产物信息, -// 上层负责将其映射为正式协议对象。 +// ArtifactRef is a module-local reference to an artifact produced by tool work. +// It does not replace the protocol Artifact shape; callers map it to the formal +// delivery boundary. type ArtifactRef struct { ArtifactType string `json:"artifact_type"` Title string `json:"title"` @@ -153,7 +149,7 @@ type ArtifactRef struct { MimeType string `json:"mime_type"` } -// ToolResultError 描述工具执行失败的归一化错误信息。 +// ToolResultError is normalized error information returned by tool execution. type ToolResultError struct { Code int `json:"code"` Message string `json:"message"` @@ -161,49 +157,44 @@ type ToolResultError struct { } // --------------------------------------------------------------------------- -// Tool 接口 +// Tool interface. // --------------------------------------------------------------------------- -// Tool 是所有工具必须实现的核心接口。 +// Tool is the core interface implemented by every executable tool. // -// Metadata 返回工具的静态元信息,用于 registry 注册和 executor 查找。 -// Validate 在执行前对输入做业务级校验,避免无效输入进入执行路径。 -// Execute 执行工具逻辑,接收 ToolExecuteContext 和原始输入, -// 返回归一化的 ToolResult。 +// Metadata returns static registry data. Validate performs business-level input +// checks before execution. Execute performs tool work with ToolExecuteContext and +// raw input, then returns normalized ToolResult data. // -// 约束: -// - Execute 必须能产出 ToolCall 记录所需的全部数据; -// - Execute 不得直接推进 task/run/step/event 状态机; -// - Execute 不得直接编排 delivery_result; -// - Execute 不得返回未登记的临时 JSON。 +// Execute must provide all data required for ToolCall recording, must not advance +// task/run/step/event state machines directly, must not assemble delivery_result +// directly, and must not return unregistered ad hoc JSON as a formal result. type Tool interface { Metadata() ToolMetadata Validate(input map[string]any) error Execute(ctx context.Context, execCtx *ToolExecuteContext, input map[string]any) (*ToolResult, error) } -// DryRunTool 是可选接口,支持预检查模式的工具可以实现它。 +// DryRunTool is implemented by tools that support precheck mode. type DryRunTool interface { Tool DryRun(ctx context.Context, execCtx *ToolExecuteContext, input map[string]any) (*ToolResult, error) } // --------------------------------------------------------------------------- -// ToolExecuteContext:工具执行上下文 +// Tool execution context. // --------------------------------------------------------------------------- -// StorageCapability 是 tools 模块所需的存储能力最小接口。 -// -// 不直接引用 storage 包内部类型,避免 tools 与 storage 产生编译期耦合。 -// 具体实现由 bootstrap 通过 storage 适配注入。 +// StorageCapability is the minimal storage dependency required by tools. +// It avoids compile-time coupling to storage internals; bootstrap injects the +// concrete adapter. type StorageCapability interface { DatabasePath() string } -// PlatformCapability 是 tools 模块所需的平台能力最小接口。 -// -// 不直接引用 platform 包内部类型,避免 tools 与 platform 产生编译期耦合。 -// 具体实现由 bootstrap 通过 platform 适配注入。 +// PlatformCapability is the minimal platform dependency required by tools. +// It avoids compile-time coupling to platform internals; bootstrap injects the +// concrete adapter. type PlatformCapability interface { Join(elem ...string) string Abs(path string) (string, error) @@ -214,28 +205,22 @@ type PlatformCapability interface { Stat(path string) (fs.FileInfo, error) } -// RiskEvaluator 是 tools 模块所需的风险评估最小接口。 -// -// 不直接引用 risk 包内部类型,由 bootstrap 注入。 +// RiskEvaluator is the minimal risk-evaluation boundary required by tools. type RiskEvaluator interface { EvaluateOperation(operationName string, targetObject string) (riskLevel string, err error) } -// AuditWriter 是 tools 模块所需的审计写入最小接口。 -// -// 不直接引用 audit 包内部类型,由 bootstrap 注入。 +// AuditWriter is the minimal audit-write boundary required by tools. type AuditWriter interface { WriteAuditRecord(taskID, runID, auditType, action, summary, target, result string) error } -// ExecutionCapability 是 tools 模块所需的最小执行后端接口。 -// -// 该接口用于受控命令执行工具,不直接暴露平台实现细节。 +// ExecutionCapability is the minimal controlled-command execution backend. type ExecutionCapability interface { RunCommand(ctx context.Context, command string, args []string, workingDir string) (CommandExecutionResult, error) } -// CommandExecutionResult 描述一次受控命令执行的最小输出。 +// CommandExecutionResult is the minimal output from one controlled command. type CommandExecutionResult struct { Stdout string Stderr string @@ -282,7 +267,7 @@ type BrowserExecutionMetadata struct { EndpointURL string } -// BrowserPageReadResult 描述浏览器页面读取的最小结果。 +// BrowserPageReadResult is the minimal output of one browser page read. type BrowserPageReadResult struct { BrowserExecutionMetadata URL string @@ -293,7 +278,7 @@ type BrowserPageReadResult struct { Source string } -// BrowserPageSearchResult 描述页面内基础搜索的最小结果。 +// BrowserPageSearchResult is the minimal output of one browser page search. type BrowserPageSearchResult struct { BrowserExecutionMetadata URL string @@ -573,35 +558,26 @@ type ScreenCaptureClient interface { CleanupExpiredScreenTemps(ctx context.Context, input ScreenCleanupInput) (ScreenCleanupResult, error) } -// CheckpointService 是 tools 模块所需的恢复点最小接口。 -// -// 不直接引用 checkpoint 包内部类型,由 bootstrap 注入。 +// CheckpointService is the minimal recovery-point boundary required by tools. type CheckpointService interface { CreateRecoveryPoint(taskID, summary string, objects []string) error } -// ModelCapability 是 tools 模块所需的统一模型能力接口。 -// -// 工具层只能通过这层接口访问模型接入,不得直接在工具实现里散落 SDK 调用。 -// 具体实现由 internal/model.Service 提供,并由 bootstrap 注入到执行上下文。 +// ModelCapability is the unified model boundary exposed to tool implementations. +// Tool code must use this interface instead of calling provider SDKs directly. type ModelCapability interface { GenerateText(ctx context.Context, request model.GenerateTextRequest) (model.GenerateTextResponse, error) Provider() string ModelID() string } -// ToolExecuteContext 携带单次工具执行所需的全部运行时上下文。 +// ToolExecuteContext carries all runtime state needed for one tool execution. // -// task_id / run_id / step_id 与协议层的 Task / Run / Step 对齐, -// trace_id 用于链路追踪。 -// workspace_path 是当前工作区根路径,平台路径操作必须通过 -// PlatformCapability 完成,不能直接拼接。 -// logger 保留为 any 类型,避免引入具体日志库依赖, -// 工具实现按需做类型断言或通过简单接口使用。 -// timeout 和 cancel 由 executor 在创建 context 时设置, -// 工具实现应尊重 ctx.Done() 信号。 -// storage / platform / risk / audit / checkpoint 均为可选注入, -// 工具实现使用前需做 nil 检查,不得假设一定可用。 +// TaskID, RunID, and StepID align with protocol Task/Run/Step identities. TraceID +// supports trace correlation. WorkspacePath is the workspace root; path work must +// go through PlatformCapability. Logger remains any to avoid binding the tools +// package to a logging library. Timeout and Cancel are executor-owned, and tools +// must honor ctx.Done(). Optional dependencies must be nil-checked before use. type ToolExecuteContext struct { TaskID string RunID string @@ -629,27 +605,27 @@ type ToolExecuteContext struct { } // --------------------------------------------------------------------------- -// 错误类型 +// Error values. // --------------------------------------------------------------------------- var ( - // ErrToolNameRequired 表示工具名称不能为空。 + // ErrToolNameRequired reports a missing tool name. ErrToolNameRequired = errors.New("tools: tool name is required") - // ErrToolNameInvalid 表示工具名称不符合 snake_case 规范。 + // ErrToolNameInvalid reports a tool name that is not snake_case. ErrToolNameInvalid = errors.New("tools: tool name is invalid") - // ErrToolSourceRequired 表示工具来源不能为空。 + // ErrToolSourceRequired reports a missing tool source. ErrToolSourceRequired = errors.New("tools: tool source is required") - // ErrToolSourceInvalid 表示工具来源不在允许范围内。 + // ErrToolSourceInvalid reports an unsupported tool source. ErrToolSourceInvalid = errors.New("tools: tool source is invalid") - // ErrToolDisplayNameRequired 表示工具显示名称不能为空。 + // ErrToolDisplayNameRequired reports a missing display name. ErrToolDisplayNameRequired = errors.New("tools: tool display_name is required") - // ErrToolNotFound 表示请求的工具未在 registry 中注册。 + // ErrToolNotFound reports a registry lookup miss. ErrToolNotFound = errors.New("tools: tool not found") - // ErrToolValidationFailed 表示工具输入校验失败。 + // ErrToolValidationFailed reports invalid tool input. ErrToolValidationFailed = errors.New("tools: tool validation failed") - // ErrToolExecutionFailed 表示工具执行过程中发生错误。 + // ErrToolExecutionFailed reports a tool execution failure. ErrToolExecutionFailed = errors.New("tools: tool execution failed") - // ErrToolExecutionTimeout 表示工具执行超时。 + // ErrToolExecutionTimeout reports a tool execution timeout. ErrToolExecutionTimeout = errors.New("tools: tool execution timeout") // ErrToolOutputInvalid indicates invalid tool output. ErrToolOutputInvalid = errors.New("tools: tool output invalid") @@ -673,27 +649,26 @@ var ( ErrScreenKeyframeSamplingFailed = errors.New("tools: screen keyframe sampling failed") // ErrScreenCleanupFailed indicates temporary screen artifact cleanup failed. ErrScreenCleanupFailed = errors.New("tools: screen artifact cleanup failed") - // ErrToolDryRunNotSupported 表示工具不支持预检查模式。 + // ErrToolDryRunNotSupported reports a tool that cannot run in precheck mode. ErrToolDryRunNotSupported = errors.New("tools: tool dry run not supported") - // ErrToolDuplicateName 表示注册时发现同名工具已存在。 + // ErrToolDuplicateName reports a duplicate registry name. ErrToolDuplicateName = errors.New("tools: duplicate tool name") - // ErrApprovalRequired 表示命中审批门禁,当前执行被阻塞。 + // ErrApprovalRequired reports an execution blocked by authorization gates. ErrApprovalRequired = errors.New("tools: approval required") - // ErrWorkspaceBoundaryDenied 表示目标路径超出工作区边界。 + // ErrWorkspaceBoundaryDenied reports a target outside the workspace boundary. ErrWorkspaceBoundaryDenied = errors.New("tools: workspace boundary denied") - // ErrCommandNotAllowed 表示命中了被拦截的危险命令。 + // ErrCommandNotAllowed reports a blocked dangerous command. ErrCommandNotAllowed = errors.New("tools: command not allowed") - // ErrCapabilityDenied 表示当前平台能力不足,无法安全执行。 + // ErrCapabilityDenied reports missing platform capability for safe execution. ErrCapabilityDenied = errors.New("tools: capability denied") ) // --------------------------------------------------------------------------- -// 辅助函数 +// Helpers. // --------------------------------------------------------------------------- -// isSnakeCase 判断字符串是否符合 snake_case 规范。 -// -// 允许小写字母、数字和下划线,不允许大写字母、连字符或空格。 +// isSnakeCase reports whether s uses lower snake_case with digits after the +// first character. func isSnakeCase(s string) bool { if s == "" { return false From a5ce9f1717673a97002b8f4e783ad8d4b0ec794a Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sat, 9 May 2026 20:11:40 +0800 Subject: [PATCH 02/41] ci(local-service): block chinese go comments --- scripts/ci/README.md | 13 ++-- scripts/ci/local-service-style/main.go | 66 +++++++++++++++++++++ scripts/ci/local-service-style/main_test.go | 27 +++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/scripts/ci/README.md b/scripts/ci/README.md index d4e59f4d2..e2c141b74 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -15,12 +15,13 @@ go test ./services/local-service/... ``` `local-service-style` verifies that `goimports` has been applied to changed Go -files under `services/local-service` and rejects newly added Chinese Go -comments in local-service diffs. In pull requests, CI passes the base SHA so -the guard checks only new changes instead of failing on historical debt that is -outside the current change boundary. In local mode, the comment guard evaluates -staged hunks against the index snapshot and unstaged hunks against the working -tree, so partial staging in the same file does not skew reported line numbers. +files under `services/local-service`, rejects newly added Chinese Go comments in +local-service diffs, and scans all current `services/local-service` Go comments +so historical comment debt cannot silently return. In pull requests, CI passes +the base SHA so added-comment diagnostics still point at the PR diff. In local +mode, the added-comment guard evaluates staged hunks against the index snapshot +and unstaged hunks against the working tree, so partial staging in the same file +does not skew reported line numbers. The repository pins `goimports` and `staticcheck` through the root [`go.mod`](../go.mod), so local runs and CI stay on the same tool versions until the module is diff --git a/scripts/ci/local-service-style/main.go b/scripts/ci/local-service-style/main.go index 1fb5ee027..288136a87 100644 --- a/scripts/ci/local-service-style/main.go +++ b/scripts/ci/local-service-style/main.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strconv" "strings" "unicode" @@ -75,6 +76,9 @@ func run() error { if err := checkAddedComments(root, baseRef); err != nil { return err } + if err := checkAllComments(root); err != nil { + return err + } } return nil } @@ -158,6 +162,23 @@ func checkAddedComments(root, baseRef string) error { return errors.New(strings.TrimRight(builder.String(), "\n")) } +func checkAllComments(root string) error { + violations, err := findAllCommentViolations(root) + if err != nil { + return err + } + if len(violations) == 0 { + return nil + } + + var builder strings.Builder + builder.WriteString("Chinese characters are not allowed in local-service Go comments:\n") + for _, item := range violations { + fmt.Fprintf(&builder, "%s:%d: %s\n", item.file, item.line, strings.TrimSpace(item.text)) + } + return errors.New(strings.TrimRight(builder.String(), "\n")) +} + func collectDiffChunks(root, baseRef string) ([]diffChunk, error) { if baseRef != "" && !isZeroRevision(baseRef) { if diff, err := gitDiff(root, baseRef+"...HEAD"); err == nil { @@ -298,6 +319,51 @@ func findCommentViolations(root string, diffChunks []diffChunk) ([]violation, er return violations, nil } +func findAllCommentViolations(root string) ([]violation, error) { + basePath := filepath.Join(root, localServicePath) + var violations []violation + + if err := filepath.WalkDir(basePath, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + return nil + } + + relativePath, err := filepath.Rel(root, path) + if err != nil { + return err + } + relativePath = filepath.ToSlash(relativePath) + + commentLines, err := scanCommentLines(root, relativePath, workingTreeFileReader) + if err != nil { + return err + } + for line, comments := range commentLines { + for _, comment := range comments { + if containsHan(comment) { + violations = append(violations, violation{file: relativePath, line: line, text: comment}) + } + } + } + + return nil + }); err != nil { + return nil, fmt.Errorf("scan local-service comments: %w", err) + } + + sort.SliceStable(violations, func(i, j int) bool { + if violations[i].file == violations[j].file { + return violations[i].line < violations[j].line + } + return violations[i].file < violations[j].file + }) + + return violations, nil +} + func collectAddedLines(diff string) []addedLine { var addedLines []addedLine var ( diff --git a/scripts/ci/local-service-style/main_test.go b/scripts/ci/local-service-style/main_test.go index 5b1d6a51a..bd47a6139 100644 --- a/scripts/ci/local-service-style/main_test.go +++ b/scripts/ci/local-service-style/main_test.go @@ -38,6 +38,33 @@ index 1111111..2222222 100644 } } +func TestFindAllCommentViolationsRejectsExistingChineseComments(t *testing.T) { + root := writeTestFile(t, "package demo\n// existing 中文 comment.\nconst message = \"中文 string is allowed\"\n") + + violations, err := findAllCommentViolations(root) + if err != nil { + t.Fatalf("findAllCommentViolations returned error: %v", err) + } + if len(violations) != 1 { + t.Fatalf("expected one violation, got %d: %#v", len(violations), violations) + } + if violations[0].line != 2 { + t.Fatalf("expected violation on line 2, got %d", violations[0].line) + } +} + +func TestFindAllCommentViolationsIgnoresChineseStringLiterals(t *testing.T) { + root := writeTestFile(t, "package demo\nconst message = \"中文 // not a comment\"\nconst raw = `中文 /* not a comment */`\n") + + violations, err := findAllCommentViolations(root) + if err != nil { + t.Fatalf("findAllCommentViolations returned error: %v", err) + } + if len(violations) != 0 { + t.Fatalf("expected no violations, got %#v", violations) + } +} + func TestFindCommentViolationsIgnoresChineseStringLiterals(t *testing.T) { root := writeTestFile(t, "package demo\nconst message = \"中文 // not a comment\"\nconst raw = `中文 /* not a comment */`\n") diff := `diff --git a/services/local-service/internal/demo/demo.go b/services/local-service/internal/demo/demo.go From acf5b2a87690960ef578d62a7ab084479727d4e4 Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sat, 9 May 2026 21:53:21 +0800 Subject: [PATCH 03/41] refactor(orchestrator): stage task entry flows --- .../internal/orchestrator/input_submit.go | 203 +++++++++--------- .../orchestrator/task_entry_response.go | 33 +++ .../internal/orchestrator/task_start.go | 171 +++++++++------ 3 files changed, 243 insertions(+), 164 deletions(-) create mode 100644 services/local-service/internal/orchestrator/task_entry_response.go diff --git a/services/local-service/internal/orchestrator/input_submit.go b/services/local-service/internal/orchestrator/input_submit.go index 99e8fecf8..00ef4862f 100644 --- a/services/local-service/internal/orchestrator/input_submit.go +++ b/services/local-service/internal/orchestrator/input_submit.go @@ -11,122 +11,131 @@ import ( // 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) { + flow := s.prepareInputSubmitFlow(params) + if response, handled, err := s.maybeContinueInputSubmit(&flow); err != nil || handled { + return response, err + } + if response, handled, err := s.maybeHandleSuggestedInputScreen(flow); err != nil || handled { + return response, err + } + if response, handled := s.maybeRouteUnanchoredInput(&flow); handled { + return response, nil + } + flow.PreferredDelivery, flow.FallbackDelivery = inputSubmitDeliveryPreference(flow) + if response, handled, err := s.maybeCreateWaitingInputTask(flow); err != nil || handled { + return response, err + } + + task := s.createTaskFromEntryFlow(flow) + return s.finishInputSubmit(flow, task) +} + +func (s *Service) prepareInputSubmitFlow(params map[string]any) taskEntryFlow { snapshot := s.context.Capture(params) options := mapValue(params, "options") confirmRequired := boolValue(options, "confirm_required", false) - if response, handled, resolvedSessionID, err := s.maybeContinueExistingTask(params, snapshot, nil, taskContinuationOptions{ + suggestion := s.intent.Suggest(snapshot, nil, confirmRequired) + return taskEntryFlow{ + Params: params, + Snapshot: snapshot, + Options: options, ConfirmRequired: confirmRequired, ForceConfirmRequired: confirmRequired, - }); err != nil { - return nil, err - } else if handled { - return response, nil - } else if strings.TrimSpace(resolvedSessionID) != "" { - params = withResolvedSessionID(params, resolvedSessionID) + Suggestion: s.normalizeSuggestedIntentForAvailability(snapshot, suggestion, confirmRequired), } - suggestion := s.intent.Suggest(snapshot, nil, confirmRequired) - suggestion = s.normalizeSuggestedIntentForAvailability(snapshot, suggestion, confirmRequired) - if handledResponse, handled, err := s.handleScreenAnalyzeSuggestion(params, snapshot, suggestion); err != nil { - return nil, err - } else if handled { - return handledResponse, nil - } - if decision, ok := s.routeUnanchoredSubmitInput(context.Background(), snapshot, suggestion, confirmRequired); ok { - if decision.Route == inputRouteSocialChat { - return s.socialChatInputResponse(decision), nil - } - suggestion = applyInputRouteDecision(suggestion, decision) - } - preferredDelivery, fallbackDelivery := deliveryPreferenceFromSubmit(params) - if !suggestion.RequiresConfirm { - preferredDelivery, fallbackDelivery = mergeSuggestedDeliveryPreference(preferredDelivery, fallbackDelivery, suggestion.DirectDeliveryType) - } - if s.intent.AnalyzeSnapshot(snapshot) == "waiting_input" { - task := s.runEngine.CreateTask(runengine.CreateTaskInput{ - SessionID: stringValue(params, "session_id", ""), - RequestSource: stringValue(params, "source", ""), - RequestTrigger: stringValue(params, "trigger", ""), - Title: "等待补充输入", - SourceType: suggestion.TaskSourceType, - Status: "waiting_input", - Intent: nil, - PreferredDelivery: preferredDelivery, - FallbackDelivery: fallbackDelivery, - CurrentStep: "collect_input", - RiskLevel: s.risk.DefaultLevel(), - Timeline: initialTimeline("waiting_input", "collect_input"), - Snapshot: snapshot, - }) +} + +func (s *Service) maybeContinueInputSubmit(flow *taskEntryFlow) (map[string]any, bool, error) { + response, handled, resolvedSessionID, err := s.maybeContinueExistingTask(flow.Params, flow.Snapshot, nil, taskContinuationOptions{ + ConfirmRequired: flow.ConfirmRequired, + ForceConfirmRequired: flow.ForceConfirmRequired, + }) + if err != nil || handled { + return response, handled, err + } + if strings.TrimSpace(resolvedSessionID) != "" { + flow.Params = withResolvedSessionID(flow.Params, resolvedSessionID) + } + return nil, false, nil +} + +func (s *Service) maybeHandleSuggestedInputScreen(flow taskEntryFlow) (map[string]any, bool, error) { + return s.handleScreenAnalyzeSuggestion(flow.Params, flow.Snapshot, flow.Suggestion) +} - bubble := s.delivery.BuildBubbleMessage(task.TaskID, "status", "请先告诉我你希望我处理什么内容。", task.StartedAt.Format(dateTimeLayout)) - if _, ok := s.runEngine.SetPresentation(task.TaskID, bubble, nil, nil); ok { - task, _ = s.runEngine.GetTask(task.TaskID) - } +func (s *Service) maybeRouteUnanchoredInput(flow *taskEntryFlow) (map[string]any, bool) { + decision, ok := s.routeUnanchoredSubmitInput(context.Background(), flow.Snapshot, flow.Suggestion, flow.ConfirmRequired) + if !ok { + return nil, false + } + if decision.Route == inputRouteSocialChat { + return s.socialChatInputResponse(decision), true + } + flow.Suggestion = applyInputRouteDecision(flow.Suggestion, decision) + return nil, false +} - return map[string]any{ - "task": taskMap(task), - "bubble_message": bubble, - "delivery_result": nil, - }, nil +func inputSubmitDeliveryPreference(flow taskEntryFlow) (string, string) { + preferredDelivery, fallbackDelivery := deliveryPreferenceFromSubmit(flow.Params) + if !flow.Suggestion.RequiresConfirm { + return mergeSuggestedDeliveryPreference(preferredDelivery, fallbackDelivery, flow.Suggestion.DirectDeliveryType) } + return preferredDelivery, fallbackDelivery +} +func (s *Service) maybeCreateWaitingInputTask(flow taskEntryFlow) (map[string]any, bool, error) { + if s.intent.AnalyzeSnapshot(flow.Snapshot) != "waiting_input" { + return nil, false, nil + } task := s.runEngine.CreateTask(runengine.CreateTaskInput{ - SessionID: stringValue(params, "session_id", ""), - RequestSource: stringValue(params, "source", ""), - RequestTrigger: stringValue(params, "trigger", ""), - Title: suggestion.TaskTitle, - SourceType: suggestion.TaskSourceType, - Status: taskStatusForSuggestion(suggestion.RequiresConfirm), - Intent: suggestion.Intent, - PreferredDelivery: preferredDelivery, - FallbackDelivery: fallbackDelivery, - CurrentStep: currentStepForSuggestion(suggestion.RequiresConfirm, suggestion.Intent), + SessionID: stringValue(flow.Params, "session_id", ""), + RequestSource: stringValue(flow.Params, "source", ""), + RequestTrigger: stringValue(flow.Params, "trigger", ""), + Title: "等待补充输入", + SourceType: flow.Suggestion.TaskSourceType, + Status: "waiting_input", + Intent: nil, + PreferredDelivery: flow.PreferredDelivery, + FallbackDelivery: flow.FallbackDelivery, + CurrentStep: "collect_input", RiskLevel: s.risk.DefaultLevel(), - Timeline: initialTimeline(taskStatusForSuggestion(suggestion.RequiresConfirm), currentStepForSuggestion(suggestion.RequiresConfirm, suggestion.Intent)), - Snapshot: snapshot, + Timeline: initialTimeline("waiting_input", "collect_input"), + Snapshot: flow.Snapshot, }) - s.publishTaskStart(task.TaskID, task.SessionID, requestTraceID(params)) - s.attachMemoryReadPlans(task.TaskID, task.RunID, snapshot, suggestion.Intent) - bubble := s.delivery.BuildBubbleMessage(task.TaskID, bubbleTypeForSuggestion(suggestion.RequiresConfirm), bubbleTextForInput(suggestion), task.StartedAt.Format(dateTimeLayout)) - deliveryResult := map[string]any(nil) - if !suggestion.RequiresConfirm { - if queuedTask, queueBubble, queued, queueErr := s.queueTaskIfSessionBusy(task); queueErr != nil { - return nil, queueErr - } else if queued { - task = queuedTask - bubble = queueBubble - } else { - governedTask, governedResponse, handled, governanceErr := s.handleTaskGovernanceDecision(task, suggestion.Intent) - if governanceErr != nil { - return nil, governanceErr - } - if handled { - return governedResponse, nil - } - task = governedTask - var execErr error - task, bubble, deliveryResult, _, execErr = s.executeTask(task, snapshot, suggestion.Intent) - if execErr != nil { - return nil, execErr - } - } - } else { - if _, ok := s.runEngine.SetPresentation(task.TaskID, bubble, nil, nil); ok { - task, _ = s.runEngine.GetTask(task.TaskID) - } + bubble := s.delivery.BuildBubbleMessage(task.TaskID, "status", "请先告诉我你希望我处理什么内容。", task.StartedAt.Format(dateTimeLayout)) + task = s.persistTaskPresentation(task, bubble) + return buildTaskEntryResponse(task, bubble, nil), true, nil +} + +func (s *Service) finishInputSubmit(flow taskEntryFlow, task runengine.TaskRecord) (map[string]any, 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 + } + if queuedTask, queueBubble, queued, queueErr := s.queueTaskIfSessionBusy(task); queueErr != nil { + return nil, queueErr + } else if queued { + return buildTaskEntryResponse(queuedTask, queueBubble, nil), nil } - response := map[string]any{ - "task": taskMap(task), - "bubble_message": bubble, - "delivery_result": nil, + governedTask, governedResponse, handled, governanceErr := s.handleTaskGovernanceDecision(task, flow.Suggestion.Intent) + if governanceErr != nil { + return nil, governanceErr } - if deliveryResult != nil { - response["delivery_result"] = deliveryResult + if handled { + return governedResponse, nil } - return response, nil + task = governedTask + deliveryResult := map[string]any(nil) + var execErr error + task, bubble, deliveryResult, _, execErr = s.executeTask(task, flow.Snapshot, flow.Suggestion.Intent) + if execErr != nil { + return nil, execErr + } + return buildTaskEntryResponse(task, bubble, deliveryResult), nil } // deliveryPreferenceFromSubmit reads delivery preferences from diff --git a/services/local-service/internal/orchestrator/task_entry_response.go b/services/local-service/internal/orchestrator/task_entry_response.go new file mode 100644 index 000000000..d250e0d5b --- /dev/null +++ b/services/local-service/internal/orchestrator/task_entry_response.go @@ -0,0 +1,33 @@ +package orchestrator + +import "github.com/cialloclaw/cialloclaw/services/local-service/internal/runengine" + +// persistTaskPresentation stores the current lightweight presentation on the +// task record before the response leaves the orchestrator. Confirmation and +// waiting-input branches use this path because no execution result will later +// refresh the task projection for the frontend. +func (s *Service) persistTaskPresentation(task runengine.TaskRecord, bubble map[string]any) runengine.TaskRecord { + if _, ok := s.runEngine.SetPresentation(task.TaskID, bubble, nil, nil); !ok { + return task + } + updatedTask, ok := s.runEngine.GetTask(task.TaskID) + if !ok { + return task + } + return updatedTask +} + +// 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, + } + if len(deliveryResult) > 0 { + response["delivery_result"] = deliveryResult + } + return response +} diff --git a/services/local-service/internal/orchestrator/task_start.go b/services/local-service/internal/orchestrator/task_start.go index 4478c22ba..391510db2 100644 --- a/services/local-service/internal/orchestrator/task_start.go +++ b/services/local-service/internal/orchestrator/task_start.go @@ -13,87 +13,131 @@ import ( // 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) { + flow := s.prepareStartTaskFlow(params) + if response, handled, err := s.maybeContinueStartTask(&flow); err != nil || handled { + return response, err + } + if response, handled, err := s.maybeHandleExplicitScreenStart(flow); err != nil || handled { + return response, err + } + + flow.Suggestion = s.suggestStartTaskIntent(flow) + if response, handled, err := s.maybeHandleSuggestedScreenStart(flow); err != nil || handled { + return response, err + } + + flow.PreferredDelivery, flow.FallbackDelivery = startTaskDeliveryPreference(flow) + task := s.createTaskFromEntryFlow(flow) + return s.finishStartTask(flow, task) +} + +type taskEntryFlow struct { + Params map[string]any + Snapshot contextsvc.TaskContextSnapshot + ExplicitIntent map[string]any + Options map[string]any + ConfirmRequired bool + ForceConfirmRequired bool + Suggestion intent.Suggestion + PreferredDelivery string + FallbackDelivery string +} + +func (s *Service) prepareStartTaskFlow(params map[string]any) taskEntryFlow { snapshot := s.context.Capture(params) explicitIntent := mapValue(params, "intent") options := mapValue(params, "options") forceConfirmRequired := boolValue(options, "confirm_required", false) - confirmRequired := taskStartConfirmRequired(snapshot, explicitIntent, forceConfirmRequired) - if response, handled, resolvedSessionID, err := s.maybeContinueExistingTask(params, snapshot, explicitIntent, taskContinuationOptions{ - ConfirmRequired: confirmRequired, + + return taskEntryFlow{ + Params: params, + Snapshot: snapshot, + ExplicitIntent: explicitIntent, + Options: options, + ConfirmRequired: taskStartConfirmRequired(snapshot, explicitIntent, forceConfirmRequired), ForceConfirmRequired: forceConfirmRequired, - }); err != nil { - return nil, err - } else if handled { - return response, nil - } else if strings.TrimSpace(resolvedSessionID) != "" { - params = withResolvedSessionID(params, resolvedSessionID) - } - if handledResponse, handled, err := s.handleScreenAnalyzeStart(params, snapshot, explicitIntent); err != nil { - return nil, err - } else if handled { - return handledResponse, nil - } - suggestion := s.intent.Suggest(snapshot, explicitIntent, confirmRequired) - fallbackConfirmRequired := confirmRequired + } +} + +func (s *Service) maybeContinueStartTask(flow *taskEntryFlow) (map[string]any, bool, error) { + response, handled, resolvedSessionID, err := s.maybeContinueExistingTask(flow.Params, flow.Snapshot, flow.ExplicitIntent, taskContinuationOptions{ + ConfirmRequired: flow.ConfirmRequired, + ForceConfirmRequired: flow.ForceConfirmRequired, + }) + if err != nil || handled { + return response, handled, err + } + if strings.TrimSpace(resolvedSessionID) != "" { + flow.Params = withResolvedSessionID(flow.Params, resolvedSessionID) + } + return nil, false, nil +} + +func (s *Service) maybeHandleExplicitScreenStart(flow taskEntryFlow) (map[string]any, bool, error) { + return s.handleScreenAnalyzeStart(flow.Params, flow.Snapshot, flow.ExplicitIntent) +} + +func (s *Service) suggestStartTaskIntent(flow taskEntryFlow) intent.Suggestion { + suggestion := s.intent.Suggest(flow.Snapshot, flow.ExplicitIntent, flow.ConfirmRequired) + fallbackConfirmRequired := flow.ConfirmRequired // Screen inference already carries its own authorization boundary; only an // explicit caller request should turn an unavailable screen path back into // intent confirmation. - if stringValue(suggestion.Intent, "name", "") == "screen_analyze" && !forceConfirmRequired { + if stringValue(suggestion.Intent, "name", "") == "screen_analyze" && !flow.ForceConfirmRequired { fallbackConfirmRequired = suggestion.RequiresConfirm } - suggestion = s.normalizeSuggestedIntentForAvailability(snapshot, suggestion, fallbackConfirmRequired) - if handledResponse, handled, err := s.handleScreenAnalyzeSuggestion(params, snapshot, suggestion); err != nil { - return nil, err - } else if handled { - return handledResponse, nil - } - preferredDelivery, fallbackDelivery := deliveryPreferenceFromStart(params) - if len(explicitIntent) == 0 && !suggestion.RequiresConfirm { - preferredDelivery, fallbackDelivery = mergeSuggestedDeliveryPreference(preferredDelivery, fallbackDelivery, suggestion.DirectDeliveryType) + return s.normalizeSuggestedIntentForAvailability(flow.Snapshot, suggestion, fallbackConfirmRequired) +} + +func (s *Service) maybeHandleSuggestedScreenStart(flow taskEntryFlow) (map[string]any, bool, error) { + return s.handleScreenAnalyzeSuggestion(flow.Params, flow.Snapshot, flow.Suggestion) +} + +func startTaskDeliveryPreference(flow taskEntryFlow) (string, string) { + preferredDelivery, fallbackDelivery := deliveryPreferenceFromStart(flow.Params) + if len(flow.ExplicitIntent) == 0 && !flow.Suggestion.RequiresConfirm { + return mergeSuggestedDeliveryPreference(preferredDelivery, fallbackDelivery, flow.Suggestion.DirectDeliveryType) } + return preferredDelivery, fallbackDelivery +} +func (s *Service) createTaskFromEntryFlow(flow taskEntryFlow) runengine.TaskRecord { + status := taskStatusForSuggestion(flow.Suggestion.RequiresConfirm) + currentStep := currentStepForSuggestion(flow.Suggestion.RequiresConfirm, flow.Suggestion.Intent) task := s.runEngine.CreateTask(runengine.CreateTaskInput{ - SessionID: stringValue(params, "session_id", ""), - RequestSource: stringValue(params, "source", ""), - RequestTrigger: stringValue(params, "trigger", ""), - Title: suggestion.TaskTitle, - SourceType: suggestion.TaskSourceType, - Status: taskStatusForSuggestion(suggestion.RequiresConfirm), - Intent: suggestion.Intent, - PreferredDelivery: preferredDelivery, - FallbackDelivery: fallbackDelivery, - CurrentStep: currentStepForSuggestion(suggestion.RequiresConfirm, suggestion.Intent), + SessionID: stringValue(flow.Params, "session_id", ""), + RequestSource: stringValue(flow.Params, "source", ""), + RequestTrigger: stringValue(flow.Params, "trigger", ""), + Title: flow.Suggestion.TaskTitle, + SourceType: flow.Suggestion.TaskSourceType, + Status: status, + Intent: flow.Suggestion.Intent, + PreferredDelivery: flow.PreferredDelivery, + FallbackDelivery: flow.FallbackDelivery, + CurrentStep: currentStep, RiskLevel: s.risk.DefaultLevel(), - Timeline: initialTimeline(taskStatusForSuggestion(suggestion.RequiresConfirm), currentStepForSuggestion(suggestion.RequiresConfirm, suggestion.Intent)), - Snapshot: snapshot, + Timeline: initialTimeline(status, currentStep), + Snapshot: flow.Snapshot, }) - s.publishTaskStart(task.TaskID, task.SessionID, requestTraceID(params)) - s.attachMemoryReadPlans(task.TaskID, task.RunID, snapshot, suggestion.Intent) - - bubble := s.delivery.BuildBubbleMessage(task.TaskID, bubbleTypeForSuggestion(suggestion.RequiresConfirm), bubbleTextForStart(suggestion), task.StartedAt.Format(dateTimeLayout)) - response := map[string]any{ - "task": taskMap(task), - "bubble_message": bubble, - "delivery_result": nil, - } + s.publishTaskStart(task.TaskID, task.SessionID, requestTraceID(flow.Params)) + s.attachMemoryReadPlans(task.TaskID, task.RunID, flow.Snapshot, flow.Suggestion.Intent) + return task +} - if suggestion.RequiresConfirm { - if _, ok := s.runEngine.SetPresentation(task.TaskID, bubble, nil, nil); ok { - task, _ = s.runEngine.GetTask(task.TaskID) - response["task"] = taskMap(task) - } - return response, nil +func (s *Service) finishStartTask(flow taskEntryFlow, task runengine.TaskRecord) (map[string]any, 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 } if queuedTask, queueBubble, queued, queueErr := s.queueTaskIfSessionBusy(task); queueErr != nil { return nil, queueErr } else if queued { - response["task"] = taskMap(queuedTask) - response["bubble_message"] = queueBubble - return response, nil + return buildTaskEntryResponse(queuedTask, queueBubble, nil), nil } - governedTask, governedResponse, handled, governanceErr := s.handleTaskGovernanceDecision(task, suggestion.Intent) + governedTask, governedResponse, handled, governanceErr := s.handleTaskGovernanceDecision(task, flow.Suggestion.Intent) if governanceErr != nil { return nil, governanceErr } @@ -104,18 +148,11 @@ func (s *Service) StartTask(params map[string]any) (map[string]any, error) { deliveryResult := map[string]any(nil) var execErr error - task, bubble, deliveryResult, _, execErr = s.executeTask(task, snapshot, suggestion.Intent) + task, bubble, deliveryResult, _, execErr = s.executeTask(task, flow.Snapshot, flow.Suggestion.Intent) if execErr != nil { return nil, execErr } - response["task"] = taskMap(task) - response["bubble_message"] = bubble - if len(deliveryResult) > 0 { - response["delivery_result"] = deliveryResult - } else { - response["delivery_result"] = nil - } - return response, nil + return buildTaskEntryResponse(task, bubble, deliveryResult), nil } // taskStartConfirmRequired keeps confirmation as an explicit pre-execution gate. From 9b25743b06bb351e6f248145205380fe8043ce8b Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sat, 9 May 2026 21:54:13 +0800 Subject: [PATCH 04/41] refactor(rpc): align entrypoint decoding with protocol --- .../local-service/internal/rpc/dispatch.go | 9 +- .../local-service/internal/rpc/entry_dto.go | 187 ++++++++++++++++++ .../local-service/internal/rpc/handlers.go | 68 +------ .../local-service/internal/rpc/methods.go | 104 ++++++++++ .../internal/rpc/notifications.go | 2 +- services/local-service/internal/rpc/server.go | 1 + 6 files changed, 309 insertions(+), 62 deletions(-) create mode 100644 services/local-service/internal/rpc/entry_dto.go create mode 100644 services/local-service/internal/rpc/methods.go diff --git a/services/local-service/internal/rpc/dispatch.go b/services/local-service/internal/rpc/dispatch.go index 0cf4b65bf..1288ad2d3 100644 --- a/services/local-service/internal/rpc/dispatch.go +++ b/services/local-service/internal/rpc/dispatch.go @@ -24,7 +24,7 @@ func (s *Server) dispatch(request requestEnvelope) any { }) } - params, rpcErr := decodeParams(request.Params) + params, rpcErr := s.decodeMethodParams(request.Method, request.Params) if rpcErr != nil { return newErrorEnvelope(request.ID, rpcErr) } @@ -37,6 +37,13 @@ func (s *Server) dispatch(request requestEnvelope) any { return newSuccessEnvelope(request.ID, data, s.nowRFC3339()) } +func (s *Server) decodeMethodParams(method string, rawParams []byte) (map[string]any, *rpcError) { + if spec, ok := s.methodSpecs[method]; ok { + return spec.Decode(rawParams) + } + return decodeParams(rawParams) +} + // nowRFC3339 returns the shared response timestamp format. func (s *Server) nowRFC3339() string { return s.now().Format(time.RFC3339) diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go new file mode 100644 index 000000000..82acbd8f1 --- /dev/null +++ b/services/local-service/internal/rpc/entry_dto.go @@ -0,0 +1,187 @@ +package rpc + +import ( + "bytes" + "encoding/json" +) + +// 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"` +} + +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"` +} + +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"` +} + +type selectionContext struct { + Text string `json:"text,omitempty"` +} + +type errorContext struct { + Message string `json:"message,omitempty"` +} + +type clipboardContext struct { + Text string `json:"text,omitempty"` +} + +// 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"` +} + +type agentInputSubmitInput struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + InputMode string `json:"input_mode,omitempty"` +} + +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"` +} + +type agentInputSubmitOptions struct { + ConfirmRequired bool `json:"confirm_required,omitempty"` + PreferredDelivery string `json:"preferred_delivery,omitempty"` +} + +// 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"` +} + +type agentTaskStartOptions struct { + ConfirmRequired bool `json:"confirm_required,omitempty"` +} + +// 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 decodeAgentInputSubmitParams(raw json.RawMessage) (map[string]any, *rpcError) { + var params AgentInputSubmitParams + return decodeTypedProtocolParams(raw, ¶ms) +} + +func decodeAgentTaskStartParams(raw json.RawMessage) (map[string]any, *rpcError) { + var params AgentTaskStartParams + return decodeTypedProtocolParams(raw, ¶ms) +} + +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("{}") + } + 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", + } + } + return protocolParamsMap(target) +} + +func protocolParamsMap(value any) (map[string]any, *rpcError) { + payload, err := json.Marshal(value) + if err != nil { + return nil, &rpcError{ + Code: errInvalidParams, + Message: "INVALID_PARAMS", + Detail: "params could not be normalized", + TraceID: "trace_rpc_params", + } + } + 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 params, nil +} diff --git a/services/local-service/internal/rpc/handlers.go b/services/local-service/internal/rpc/handlers.go index f080b5c47..0b4b5cf1f 100644 --- a/services/local-service/internal/rpc/handlers.go +++ b/services/local-service/internal/rpc/handlers.go @@ -1,49 +1,14 @@ // Package rpc routes stable JSON-RPC methods into the main orchestrator. package rpc -import ( - "strings" -) - -// registerHandlers binds stable agent.* JSON-RPC methods to orchestrator entry -// points. +// registerHandlers binds stable agent.* JSON-RPC methods to their protocol +// decoders and orchestrator entry points. func (s *Server) registerHandlers() { - s.handlers = map[string]methodHandler{ - "agent.input.submit": s.handleAgentInputSubmit, - "agent.task.start": s.handleAgentTaskStart, - "agent.task.confirm": s.handleAgentTaskConfirm, - "agent.task.artifact.list": s.handleAgentTaskArtifactList, - "agent.task.artifact.open": s.handleAgentTaskArtifactOpen, - "agent.delivery.open": s.handleAgentDeliveryOpen, - "agent.recommendation.get": s.handleAgentRecommendationGet, - "agent.recommendation.feedback.submit": s.handleAgentRecommendationFeedbackSubmit, - "agent.task.list": s.handleAgentTaskList, - "agent.task.detail.get": s.handleAgentTaskDetailGet, - "agent.task.events.list": s.handleAgentTaskEventsList, - "agent.task.tool_calls.list": s.handleAgentTaskToolCallsList, - "agent.task.steer": s.handleAgentTaskSteer, - "agent.task.control": s.handleAgentTaskControl, - "agent.task_inspector.config.get": s.handleAgentTaskInspectorConfigGet, - "agent.task_inspector.config.update": s.handleAgentTaskInspectorConfigUpdate, - "agent.task_inspector.run": s.handleAgentTaskInspectorRun, - "agent.notepad.list": s.handleAgentNotepadList, - "agent.notepad.update": s.handleAgentNotepadUpdate, - "agent.notepad.convert_to_task": s.handleAgentNotepadConvertToTask, - "agent.dashboard.overview.get": s.handleAgentDashboardOverviewGet, - "agent.dashboard.module.get": s.handleAgentDashboardModuleGet, - "agent.mirror.overview.get": s.handleAgentMirrorOverviewGet, - "agent.security.summary.get": s.handleAgentSecuritySummaryGet, - "agent.security.audit.list": s.handleAgentSecurityAuditList, - "agent.security.pending.list": s.handleAgentSecurityPendingList, - "agent.security.restore_points.list": s.handleAgentSecurityRestorePointsList, - "agent.security.restore.apply": s.handleAgentSecurityRestoreApply, - "agent.security.respond": s.handleAgentSecurityRespond, - "agent.settings.get": s.handleAgentSettingsGet, - "agent.settings.update": s.handleAgentSettingsUpdate, - "agent.settings.model.validate": s.handleAgentSettingsModelValidate, - "agent.plugin.runtime.list": s.handleAgentPluginRuntimeList, - "agent.plugin.list": s.handleAgentPluginList, - "agent.plugin.detail.get": s.handleAgentPluginDetailGet, + s.handlers = map[string]methodHandler{} + s.methodSpecs = map[string]methodSpec{} + for _, method := range s.stableMethodRegistry() { + s.handlers[method.Name] = method.Handle + s.methodSpecs[method.Name] = method.methodSpec } } @@ -73,7 +38,7 @@ func (s *Server) handleAgentInputSubmit(params map[string]any) (any, *rpcError) // handleAgentTaskStart handles agent.task.start. func (s *Server) handleAgentTaskStart(params map[string]any) (any, *rpcError) { - data, err := s.orchestrator.StartTask(sanitizeTaskStartParams(params)) + data, err := s.orchestrator.StartTask(params) return wrapOrchestratorResult(data, err) } @@ -262,23 +227,6 @@ func (s *Server) handleAgentPluginDetailGet(params map[string]any) (any, *rpcErr return wrapOrchestratorResult(data, err) } -// sanitizeTaskStartParams strips any client-supplied intent payload so -// agent.task.start continues to flow through the authoritative suggestion path. -func sanitizeTaskStartParams(params map[string]any) map[string]any { - if len(params) == 0 { - return nil - } - - sanitized := make(map[string]any, len(params)) - for key, value := range params { - if strings.TrimSpace(key) == "intent" { - continue - } - sanitized[key] = value - } - return sanitized -} - // wrapOrchestratorResult maps orchestrator return values into the shared RPC // success/error envelope. // It does not correct business logic; it only freezes protocol-facing error diff --git a/services/local-service/internal/rpc/methods.go b/services/local-service/internal/rpc/methods.go new file mode 100644 index 000000000..8fc01fd28 --- /dev/null +++ b/services/local-service/internal/rpc/methods.go @@ -0,0 +1,104 @@ +package rpc + +import "encoding/json" + +const ( + methodAgentInputSubmit = "agent.input.submit" + methodAgentTaskStart = "agent.task.start" + methodAgentTaskConfirm = "agent.task.confirm" + methodAgentRecommendationGet = "agent.recommendation.get" + methodAgentRecommendationFeedbackSubmit = "agent.recommendation.feedback.submit" + methodAgentTaskList = "agent.task.list" + methodAgentTaskDetailGet = "agent.task.detail.get" + methodAgentTaskEventsList = "agent.task.events.list" + methodAgentTaskToolCallsList = "agent.task.tool_calls.list" + methodAgentTaskSteer = "agent.task.steer" + methodAgentTaskArtifactList = "agent.task.artifact.list" + methodAgentTaskArtifactOpen = "agent.task.artifact.open" + methodAgentTaskControl = "agent.task.control" + methodAgentTaskInspectorConfigGet = "agent.task_inspector.config.get" + methodAgentTaskInspectorConfigUpdate = "agent.task_inspector.config.update" + methodAgentTaskInspectorRun = "agent.task_inspector.run" + methodAgentNotepadList = "agent.notepad.list" + methodAgentNotepadConvertToTask = "agent.notepad.convert_to_task" + methodAgentNotepadUpdate = "agent.notepad.update" + methodAgentDashboardOverviewGet = "agent.dashboard.overview.get" + methodAgentDashboardModuleGet = "agent.dashboard.module.get" + methodAgentMirrorOverviewGet = "agent.mirror.overview.get" + methodAgentSecuritySummaryGet = "agent.security.summary.get" + methodAgentSecurityAuditList = "agent.security.audit.list" + methodAgentSecurityRestorePointsList = "agent.security.restore_points.list" + methodAgentSecurityRestoreApply = "agent.security.restore.apply" + methodAgentSecurityPendingList = "agent.security.pending.list" + methodAgentSecurityRespond = "agent.security.respond" + methodAgentDeliveryOpen = "agent.delivery.open" + methodAgentSettingsGet = "agent.settings.get" + methodAgentSettingsUpdate = "agent.settings.update" + methodAgentSettingsModelValidate = "agent.settings.model.validate" + methodAgentPluginRuntimeList = "agent.plugin.runtime.list" + methodAgentPluginList = "agent.plugin.list" + methodAgentPluginDetailGet = "agent.plugin.detail.get" +) + +type methodSpec struct { + Name string + Decode func(json.RawMessage) (map[string]any, *rpcError) +} + +type registeredMethod struct { + methodSpec + Handle methodHandler +} + +// stableMethodRegistry is the Go-side mirror of packages/protocol/rpc/methods.ts. +// The RPC layer owns method decoding so orchestrator code receives one +// normalized entry payload instead of raw transport envelopes. +func (s *Server) stableMethodRegistry() []registeredMethod { + return []registeredMethod{ + registered(methodAgentInputSubmit, decodeAgentInputSubmitParams, s.handleAgentInputSubmit), + registered(methodAgentTaskStart, decodeAgentTaskStartParams, s.handleAgentTaskStart), + registered(methodAgentTaskConfirm, decodeParams, 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), + } +} + +func registered(name string, decode func(json.RawMessage) (map[string]any, *rpcError), handle methodHandler) registeredMethod { + return registeredMethod{ + methodSpec: methodSpec{ + Name: name, + Decode: decode, + }, + Handle: handle, + } +} diff --git a/services/local-service/internal/rpc/notifications.go b/services/local-service/internal/rpc/notifications.go index 670bd22e5..44fad071a 100644 --- a/services/local-service/internal/rpc/notifications.go +++ b/services/local-service/internal/rpc/notifications.go @@ -44,7 +44,7 @@ func requestRoutingHints(request requestEnvelope) (map[string]bool, string, stri } func shouldTrackStartedTask(method string) bool { - return method == "agent.task.start" || method == "agent.input.submit" + return method == methodAgentTaskStart || method == methodAgentInputSubmit } // shouldClaimResponseTaskOwnership scopes late response-based task ownership to diff --git a/services/local-service/internal/rpc/server.go b/services/local-service/internal/rpc/server.go index 1f1f97607..c95a3c86c 100644 --- a/services/local-service/internal/rpc/server.go +++ b/services/local-service/internal/rpc/server.go @@ -20,6 +20,7 @@ type Server struct { namedPipeName string debugHTTPServer *http.Server handlers map[string]methodHandler + methodSpecs map[string]methodSpec orchestrator *orchestrator.Service serveNamedPipe func(ctx context.Context, pipeName string, handler func(net.Conn)) error now func() time.Time From 50c6d228c29268d100c7c026a8e624d1ea1180f9 Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sat, 9 May 2026 21:54:45 +0800 Subject: [PATCH 05/41] test(rpc): guard protocol method registry --- .../internal/rpc/protocol_registry_test.go | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 services/local-service/internal/rpc/protocol_registry_test.go diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go new file mode 100644 index 000000000..7f20928c8 --- /dev/null +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -0,0 +1,103 @@ +package rpc + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestStableMethodRegistryMatchesProtocolSource(t *testing.T) { + source, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "packages", "protocol", "rpc", "methods.ts")) + if err != nil { + t.Fatalf("read protocol method source: %v", err) + } + stableBlock := protocolStableMethodBlock(string(source)) + if stableBlock == "" { + t.Fatal("expected RPC_METHODS_STABLE block in packages/protocol/rpc/methods.ts") + } + + protocolMethods := protocolMethodSet(stableBlock) + server := newTestServer() + for _, method := range server.stableMethodRegistry() { + if !protocolMethods[method.Name] { + t.Fatalf("go rpc method %q is not declared in packages/protocol RPC_METHODS_STABLE", method.Name) + } + delete(protocolMethods, method.Name) + } + if len(protocolMethods) > 0 { + t.Fatalf("packages/protocol stable methods are missing from Go registry: %+v", protocolMethods) + } +} + +func TestAgentTaskStartDTOOmitsUnsupportedIntentField(t *testing.T) { + params, rpcErr := decodeAgentTaskStartParams(mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_dto", + "client_time": "2026-05-09T12:00:00+08:00", + }, + "session_id": "sess_task_start_dto", + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "selected content", + "page_context": map[string]any{ + "title": "Editor", + "process_id": 42, + }, + }, + "context": map[string]any{ + "selection": map[string]any{ + "text": "selected content", + }, + }, + "delivery": map[string]any{ + "preferred": "bubble", + "fallback": "task_detail", + }, + "options": map[string]any{ + "confirm_required": true, + }, + "intent": map[string]any{ + "name": "write_file", + }, + })) + if rpcErr != nil { + t.Fatalf("decode task.start params: %+v", rpcErr) + } + if _, ok := params["intent"]; ok { + t.Fatalf("expected unsupported intent field to be omitted, got %+v", params["intent"]) + } + if stringValue(params, "session_id", "") != "sess_task_start_dto" { + t.Fatalf("expected session_id to survive dto normalization, got %+v", params) + } + if delivery := mapValue(params, "delivery"); stringValue(delivery, "preferred", "") != "bubble" || stringValue(delivery, "fallback", "") != "task_detail" { + t.Fatalf("expected delivery preference to survive dto normalization, got %+v", delivery) + } + input := mapValue(params, "input") + pageContext := mapValue(input, "page_context") + if stringValue(pageContext, "title", "") != "Editor" || intValue(pageContext, "process_id", 0) != 42 { + t.Fatalf("expected page context to survive dto normalization, got %+v", pageContext) + } +} + +func protocolStableMethodBlock(source string) string { + start := strings.Index(source, "RPC_METHODS_STABLE") + end := strings.Index(source, "RPC_METHODS_PLANNED") + if start < 0 || end <= start { + return "" + } + return source[start:end] +} + +func protocolMethodSet(source string) map[string]bool { + methodPattern := regexp.MustCompile(`"agent\.[^"]+"`) + matches := methodPattern.FindAllString(source, -1) + result := make(map[string]bool, len(matches)) + for _, match := range matches { + result[strings.Trim(match, `"`)] = true + } + return result +} From 42cfff78d46a981dcba9cb23bea74a9dcef08801 Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sun, 10 May 2026 02:34:50 +0800 Subject: [PATCH 06/41] refactor(orchestrator): type task entry DTOs --- .../internal/orchestrator/input_submit.go | 10 +- .../protocol_dto_test_helpers_test.go | 25 ++ .../orchestrator/protocol_request_dto.go | 155 +++++++ .../orchestrator/protocol_response_dto.go | 284 +++++++++++++ .../internal/orchestrator/service_test.go | 399 +++++++++--------- .../internal/orchestrator/task_query.go | 10 +- .../internal/orchestrator/task_start.go | 10 +- .../local-service/internal/rpc/handlers.go | 8 +- .../local-service/internal/rpc/jsonrpc.go | 9 +- .../internal/rpc/notifications.go | 2 + .../rpc/protocol_dto_test_helpers_test.go | 27 ++ .../local-service/internal/rpc/server_test.go | 38 +- .../internal/rpc/server_transport_test.go | 4 +- 13 files changed, 753 insertions(+), 228 deletions(-) create mode 100644 services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go create mode 100644 services/local-service/internal/orchestrator/protocol_request_dto.go create mode 100644 services/local-service/internal/orchestrator/protocol_response_dto.go create mode 100644 services/local-service/internal/rpc/protocol_dto_test_helpers_test.go diff --git a/services/local-service/internal/orchestrator/input_submit.go b/services/local-service/internal/orchestrator/input_submit.go index 00ef4862f..7c53983e1 100644 --- a/services/local-service/internal/orchestrator/input_submit.go +++ b/services/local-service/internal/orchestrator/input_submit.go @@ -10,7 +10,15 @@ 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) { + response, err := s.submitInput(request.paramsMap()) + if err != nil { + return TaskEntryResponse{}, err + } + return newTaskEntryResponse(response), nil +} + +func (s *Service) submitInput(params map[string]any) (map[string]any, error) { flow := s.prepareInputSubmitFlow(params) if response, handled, err := s.maybeContinueInputSubmit(&flow); err != nil || handled { return response, err 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..b3002eaaf --- /dev/null +++ b/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go @@ -0,0 +1,25 @@ +package orchestrator + +func startTaskForTest(s *Service, params map[string]any) (map[string]any, error) { + response, err := s.StartTask(StartTaskRequestFromParams(params)) + if err != nil { + return nil, err + } + return response.Map(), nil +} + +func submitInputForTest(s *Service, params map[string]any) (map[string]any, error) { + response, err := s.SubmitInput(SubmitInputRequestFromParams(params)) + if err != nil { + return nil, err + } + return response.Map(), nil +} + +func taskDetailGetForTest(s *Service, params map[string]any) (map[string]any, error) { + response, err := s.TaskDetailGet(TaskDetailGetRequestFromParams(params)) + 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..34b9ab4a9 --- /dev/null +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -0,0 +1,284 @@ +package orchestrator + +import "encoding/json" + +// IntentPayload keeps intent arguments dynamic while making the outer protocol +// object explicit. +type IntentPayload struct { + Name string `json:"name,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` +} + +// 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"` + RunID string `json:"run_id,omitempty"` + 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"` + + raw map[string]any +} + +// 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"` + + raw map[string]any +} + +// StartTaskRequestFromParams adapts RPC-decoded params to the typed +// orchestrator request. The map is accepted only at the RPC adapter boundary. +func StartTaskRequestFromParams(params map[string]any) StartTaskRequest { + var request StartTaskRequest + decodeProtocolMap(params, &request) + 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 map is accepted only at the RPC adapter boundary. +func SubmitInputRequestFromParams(params map[string]any) SubmitInputRequest { + var request SubmitInputRequest + decodeProtocolMap(params, &request) + return request +} + +// TaskDetailGetRequestFromParams adapts RPC-decoded params to the typed +// orchestrator request. The map is accepted only at the RPC adapter boundary. +func TaskDetailGetRequestFromParams(params map[string]any) TaskDetailGetRequest { + var request TaskDetailGetRequest + decodeProtocolMap(params, &request) + return request +} + +func (r StartTaskRequest) paramsMap() map[string]any { + params := structToProtocolMap(r) + if len(r.Intent) > 0 { + params["intent"] = cloneMap(r.Intent) + } + return params +} + +func (r SubmitInputRequest) paramsMap() map[string]any { + return structToProtocolMap(r) +} + +func (r TaskDetailGetRequest) paramsMap() map[string]any { + return structToProtocolMap(r) +} + +func newTaskEntryResponse(payload map[string]any) TaskEntryResponse { + var response TaskEntryResponse + decodeProtocolMap(payload, &response) + response.raw = cloneMap(payload) + return response +} + +func newTaskDetailGetResponse(payload map[string]any) TaskDetailGetResponse { + var response TaskDetailGetResponse + decodeProtocolMap(payload, &response) + response.raw = cloneMap(payload) + return response +} + +// 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 { + if r.raw != nil { + return cloneMap(r.raw) + } + return structToProtocolMap(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 { + if r.raw != nil { + return cloneMap(r.raw) + } + return structToProtocolMap(r) +} + +func decodeProtocolMap(values map[string]any, target any) { + if len(values) == 0 { + return + } + payload, err := json.Marshal(values) + if err != nil { + return + } + _ = json.Unmarshal(payload, target) +} + +func structToProtocolMap(value any) map[string]any { + payload, err := json.Marshal(value) + if err != nil { + return map[string]any{} + } + var result map[string]any + if err := json.Unmarshal(payload, &result); err != nil { + return map[string]any{} + } + return result +} diff --git a/services/local-service/internal/orchestrator/service_test.go b/services/local-service/internal/orchestrator/service_test.go index 4dc68fba6..d85b75b09 100644 --- a/services/local-service/internal/orchestrator/service_test.go +++ b/services/local-service/internal/orchestrator/service_test.go @@ -1076,7 +1076,7 @@ func TestServiceStartTaskAndConfirmFlow(t *testing.T) { plugin.NewService(), ) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -1139,7 +1139,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{ + result, err := submitInputForTest(service, map[string]any{ "session_id": fmt.Sprintf("sess_short_text_%02d", index), "source": "floating_ball", "trigger": "hover_text_input", @@ -1177,7 +1177,7 @@ func TestServiceSubmitInputRoutesUnanchoredAmbiguousTextToConfirmation(t *testin output: `{"route":"clarification_needed","reply":""}`, }) - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_ambiguous_text", "source": "floating_ball", "trigger": "hover_text_input", @@ -1219,7 +1219,7 @@ func TestServiceSubmitInputKeepsTaskRequestAfterClassifier(t *testing.T) { }, }) - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_classified_task", "source": "floating_ball", "trigger": "hover_text_input", @@ -1260,7 +1260,7 @@ func TestServiceSubmitInputFallsBackToTaskWhenClassifierFails(t *testing.T) { }, }) - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_classifier_fallback", "source": "floating_ball", "trigger": "hover_text_input", @@ -1285,7 +1285,7 @@ func TestServiceSubmitInputFallsBackToTaskWhenClassifierFails(t *testing.T) { func TestServiceSubmitInputRespectsExplicitConfirmationForFreeText(t *testing.T) { service := newTestService() - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_confirm_free_text", "source": "floating_ball", "trigger": "hover_text_input", @@ -1317,7 +1317,7 @@ func TestServiceSubmitInputRespectsExplicitConfirmationForFreeText(t *testing.T) func TestServiceSubmitInputRoutesClearCommandToAgentLoopWithoutForcedConfirmation(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Translated note ready.") - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_clear_command", "source": "floating_ball", "trigger": "hover_text_input", @@ -1350,7 +1350,7 @@ func TestServiceSubmitInputRoutesClearCommandToAgentLoopWithoutForcedConfirmatio func TestServiceSubmitInputUsesSuggestedWorkspaceDeliveryForLongAgentLoopInput(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "Long-form result body.") - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_long_command", "source": "floating_ball", "trigger": "hover_text_input", @@ -1387,7 +1387,7 @@ func TestServiceStartTaskFailsAfterExecutionTimeout(t *testing.T) { }) service.executionTimeout = 20 * time.Millisecond - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_execution_timeout", "source": "floating_ball", "trigger": "hover_text_input", @@ -1459,7 +1459,7 @@ func TestServiceSubmitInputWorkspaceDeliverySkipsShortBubbleTimeout(t *testing.T }) service.executionTimeout = 20 * time.Millisecond - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_long_command_timeout", "source": "floating_ball", "trigger": "hover_text_input", @@ -1493,7 +1493,7 @@ func TestServiceSubmitInputWorkspaceDeliverySkipsShortBubbleTimeout(t *testing.T func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued task output.") - firstResult, err := service.StartTask(map[string]any{ + firstResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_serial", "source": "floating_ball", "trigger": "hover_text_input", @@ -1516,7 +1516,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{ + secondResult, err := submitInputForTest(service, map[string]any{ "session_id": "sess_serial", "source": "floating_ball", "trigger": "hover_text_input", @@ -1544,7 +1544,7 @@ func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *tes func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued confirm output.") - firstResult, err := service.StartTask(map[string]any{ + firstResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_confirm_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1567,7 +1567,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{ + secondResult, err := submitInputForTest(service, map[string]any{ "session_id": "sess_confirm_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1607,7 +1607,7 @@ func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued cancel output.") - firstResult, err := service.StartTask(map[string]any{ + firstResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_cancel_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1628,7 +1628,7 @@ func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *test } firstTaskID := firstResult["task"].(map[string]any)["task_id"].(string) - secondResult, err := service.SubmitInput(map[string]any{ + secondResult, err := submitInputForTest(service, map[string]any{ "session_id": "sess_cancel_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1642,7 +1642,7 @@ func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *test } secondTaskID := secondResult["task"].(map[string]any)["task_id"].(string) - thirdResult, err := service.SubmitInput(map[string]any{ + thirdResult, err := submitInputForTest(service, map[string]any{ "session_id": "sess_cancel_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1680,7 +1680,7 @@ func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *test func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Snapshot resume output.") - firstResult, err := service.StartTask(map[string]any{ + firstResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_snapshot_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1701,7 +1701,7 @@ func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing. } firstTaskID := firstResult["task"].(map[string]any)["task_id"].(string) - secondResult, err := service.StartTask(map[string]any{ + secondResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_snapshot_queue", "source": "floating_ball", "trigger": "text_selected_click", @@ -1760,7 +1760,7 @@ func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing. func TestServiceSecurityRespondResumesQueuedSessionTask(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued task resumed output.") - firstResult, err := service.StartTask(map[string]any{ + firstResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_resume_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1781,7 +1781,7 @@ func TestServiceSecurityRespondResumesQueuedSessionTask(t *testing.T) { } firstTaskID := firstResult["task"].(map[string]any)["task_id"].(string) - secondResult, err := service.SubmitInput(map[string]any{ + secondResult, err := submitInputForTest(service, map[string]any{ "session_id": "sess_resume_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1826,7 +1826,7 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * t.Fatalf("write screen input failed: %v", err) } - firstResult, err := service.StartTask(map[string]any{ + firstResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1847,7 +1847,7 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * } firstTaskID := firstResult["task"].(map[string]any)["task_id"].(string) - secondResult, err := service.StartTask(map[string]any{ + secondResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1902,7 +1902,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, map[string]any{"task_id": secondTaskID}) if err != nil { t.Fatalf("task detail get for queued screen task failed: %v", err) } @@ -1933,7 +1933,7 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * func TestServiceConfirmTaskRunsStoredAgentLoopIntentWithoutCorrection(t *testing.T) { service, _ := newTestServiceWithModelClient(t, &stubToolCallingModelClient{}) - startResult, err := service.SubmitInput(map[string]any{ + startResult, err := submitInputForTest(service, map[string]any{ "session_id": "sess_unknown_confirm", "source": "floating_ball", "trigger": "hover_text_input", @@ -1985,7 +1985,7 @@ func TestServiceConfirmTaskRunsStoredAgentLoopIntentWithoutCorrection(t *testing func TestServiceConfirmTaskKeepsUnknownIntentInConfirmationWhenRejected(t *testing.T) { service := newTestService() - startResult, err := service.SubmitInput(map[string]any{ + startResult, err := submitInputForTest(service, map[string]any{ "session_id": "sess_unknown_cancel", "source": "floating_ball", "trigger": "hover_text_input", @@ -2032,7 +2032,7 @@ func TestServiceConfirmTaskKeepsUnknownIntentInConfirmationWhenRejected(t *testi func TestServiceConfirmTaskRewritesPlaceholderTitleAfterCorrection(t *testing.T) { service := newTestService() - startResult, err := service.SubmitInput(map[string]any{ + startResult, err := submitInputForTest(service, map[string]any{ "session_id": "sess_unknown_title", "source": "floating_ball", "trigger": "hover_text_input", @@ -2070,7 +2070,7 @@ func TestServiceConfirmTaskRewritesPlaceholderTitleAfterCorrection(t *testing.T) func TestServiceConfirmTaskIgnoresCorrectedIntentWhenConfirmedTrue(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Explained content.") - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_confirm_ignore_correction", "source": "floating_ball", "trigger": "text_selected_click", @@ -2114,7 +2114,7 @@ func TestServiceConfirmTaskIgnoresCorrectedIntentWhenConfirmedTrue(t *testing.T) func TestServiceConfirmTaskRejectsOutOfPhaseRequest(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_confirm_out_of_phase", "source": "floating_ball", "trigger": "text_selected_click", @@ -2783,7 +2783,7 @@ func TestServiceNotepadUpdateReturnsDeletedItemID(t *testing.T) { func TestServiceExecutionAuditIDsStayUniqueAcrossToolAndTaskRecords(t *testing.T) { service, _ := newTestServiceWithExecution(t, "runtime output") - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_audit", "source": "floating_ball", "trigger": "text_selected_click", @@ -2825,7 +2825,7 @@ func TestServiceExecutionAuditIDsStayUniqueAcrossToolAndTaskRecords(t *testing.T func TestServiceRecommendationGetUsesRuntimeTaskState(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_recommend", "source": "floating_ball", "trigger": "text_selected_click", @@ -3387,7 +3387,7 @@ func TestServiceTaskControlRestartExecutesFreshAttempt(t *testing.T) { modelClient := &stubToolCallingModelClient{output: "Restarted loop output."} service, _ := newTestServiceWithModelClient(t, modelClient) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_restart_attempt", "source": "floating_ball", "trigger": "hover_text_input", @@ -3764,7 +3764,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, map[string]any{"task_id": task.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -3840,7 +3840,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, map[string]any{"task_id": task.TaskID}) if err != nil { t.Fatalf("task detail get during restart preparation failed: %v", err) } @@ -4044,13 +4044,12 @@ func TestServiceRecommendationFeedbackSubmitAppliesCooldown(t *testing.T) { func TestServiceSubmitInputWithFilesDoesNotWaitForInput(t *testing.T) { service := newTestService() - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_files", "source": "floating_ball", - "input": map[string]any{ - "files": []any{"workspace/notes.md"}, - }, + "input": map[string]any{}, "context": map[string]any{ + "files": []any{"workspace/notes.md"}, "page": map[string]any{ "title": "Workspace", "app_name": "desktop", @@ -4085,7 +4084,7 @@ func TestServiceSubmitInputEmptyTextReturnsWaitingInput(t *testing.T) { plugin.NewService(), ) - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -4147,7 +4146,7 @@ func TestServiceDirectStartBuildsMemoryAndDeliveryHandoffs(t *testing.T) { plugin.NewService(), ) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -4202,7 +4201,7 @@ func TestServiceDirectStartBuildsMemoryAndDeliveryHandoffs(t *testing.T) { func TestServiceStartTaskRespectsPreferredDelivery(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -4252,7 +4251,7 @@ func TestServiceStartTaskRespectsPreferredDelivery(t *testing.T) { func TestServiceStartTaskFileInstructionSkipsForcedIntentConfirmation(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Attachment summary ready.") - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_file_instruction", "source": "floating_ball", "trigger": "file_drop", @@ -4288,7 +4287,7 @@ func TestServiceStartTaskFileInstructionSkipsForcedIntentConfirmation(t *testing func TestServiceStartTaskFileWithoutInstructionStillRequiresConfirmation(t *testing.T) { service := newTestService() - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_file_needs_goal", "source": "floating_ball", "trigger": "file_drop", @@ -4330,7 +4329,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { t.Fatalf("write source file: %v", err) } - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_read_file_sample", "source": "floating_ball", "trigger": "hover_text_input", @@ -4400,7 +4399,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -4417,7 +4416,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { func TestServiceSubmitInputRespectsPreferredDelivery(t *testing.T) { service := newTestService() - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -4463,7 +4462,7 @@ func TestServiceSubmitInputRespectsPreferredDelivery(t *testing.T) { func TestServiceConfirmTaskRespectsStoredPreferredDelivery(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -4521,7 +4520,7 @@ func TestServiceStartTaskWaitingAuthDoesNotSetFinishedAt(t *testing.T) { plugin.NewService(), ) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "file_drop", @@ -4574,7 +4573,7 @@ func TestServiceConfirmCanEnterWaitingAuth(t *testing.T) { plugin.NewService(), ) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -4641,7 +4640,7 @@ func TestServiceConfirmWaitingAuthPersistsApprovalRequestRecord(t *testing.T) { t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_approval_store", "source": "floating_ball", "trigger": "text_selected_click", @@ -4694,7 +4693,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{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_approval_store_failure", "source": "floating_ball", "trigger": "text_selected_click", @@ -4739,7 +4738,7 @@ func TestServiceSecurityRespondAllowOnceResumesAndCompletes(t *testing.T) { plugin.NewService(), ) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -4832,7 +4831,7 @@ func TestServiceSecurityRespondAllowOnceResumesAndCompletes(t *testing.T) { func TestServiceSecurityRespondRespectsFallbackDelivery(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -4900,7 +4899,7 @@ func TestServiceSecurityRespondDenyOnceCancelsTask(t *testing.T) { plugin.NewService(), ) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -4965,7 +4964,7 @@ func TestServiceSecurityRespondPersistsAuthorizationRecord(t *testing.T) { t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_authorization_store", "source": "floating_ball", "trigger": "text_selected_click", @@ -5035,7 +5034,7 @@ func TestServiceSecurityRespondReturnsStorageErrorWhenAuthorizationPersistenceFa originalStore := service.storage.AuthorizationRecordStore() defer replaceAuthorizationRecordStore(t, service.storage, originalStore) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_authorization_store_failure", "source": "floating_ball", "trigger": "text_selected_click", @@ -5089,7 +5088,7 @@ func TestServiceSecurityRespondRejectsOutOfPhaseAuthorizationPersistence(t *test t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_authorization_out_of_phase", "source": "floating_ball", "trigger": "hover_text_input", @@ -5196,7 +5195,7 @@ func TestServiceSecurityRespondRejectsUnsupportedDecision(t *testing.T) { t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_unsupported_approval_decision", "source": "floating_ball", "trigger": "hover_text_input", @@ -5253,7 +5252,7 @@ func TestServiceSecurityRespondKeepsAuthorizationHistoryAcrossMultipleCycles(t * t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_authorization_history", "source": "floating_ball", "trigger": "hover_text_input", @@ -5324,7 +5323,7 @@ func TestServiceStartTaskWriteFileOverwriteWaitsForApproval(t *testing.T) { t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_overwrite", "source": "floating_ball", "trigger": "hover_text_input", @@ -5362,7 +5361,7 @@ func TestServiceStartTaskWriteFileOverwriteWaitsForApproval(t *testing.T) { func TestServiceStartTaskExecCommandWaitsForApproval(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "unused") - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec_cmd", "source": "floating_ball", "trigger": "hover_text_input", @@ -5401,7 +5400,7 @@ func TestServiceStartTaskExecCommandWaitsForApproval(t *testing.T) { func TestServiceStartTaskOutOfWorkspaceWriteIsBlocked(t *testing.T) { service, _ := newTestServiceWithExecution(t, "unused") - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_outside", "source": "floating_ball", "trigger": "hover_text_input", @@ -5438,7 +5437,7 @@ 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{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec_fail", "source": "floating_ball", "trigger": "hover_text_input", @@ -5500,7 +5499,7 @@ func TestServiceSecurityRespondAllowOnceExecCommandCompletesAfterApproval(t *tes t.Fatalf("mkdir workspace root: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec_allow", "source": "floating_ball", "trigger": "hover_text_input", @@ -5544,7 +5543,7 @@ func TestServiceSecurityRespondAllowOnceExecCommandCompletesAfterApproval(t *tes func TestServiceSecurityRespondAllowOnceCompletesDerivedWriteFileAfterApproval(t *testing.T) { service, _ := newTestServiceWithExecution(t, "新的文档内容") - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_derived_write", "source": "floating_ball", "trigger": "hover_text_input", @@ -5591,7 +5590,7 @@ func TestServiceSecurityRespondAllowOnceReturnsStructuredRecoveryFailure(t *test t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_recovery_fail", "source": "floating_ball", "trigger": "hover_text_input", @@ -5653,7 +5652,7 @@ func TestServiceTaskListSupportsSortParams(t *testing.T) { return current }) - firstResult, err := service.StartTask(map[string]any{ + firstResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -5672,7 +5671,7 @@ func TestServiceTaskListSupportsSortParams(t *testing.T) { t.Fatalf("start first task failed: %v", err) } - secondResult, err := service.StartTask(map[string]any{ + secondResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -6089,7 +6088,7 @@ func TestServiceTaskListUsesStructuredStoreUnlimitedPaginationInMemoryFallback(t func TestServiceTaskListMergesStructuredStorageWhenOffsetExceedsRuntimePage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "runtime paging") - _, err := service.StartTask(map[string]any{ + _, err := startTaskForTest(service, map[string]any{ "session_id": "sess_page", "source": "floating_ball", "trigger": "hover_text_input", @@ -6152,7 +6151,7 @@ func TestServiceTaskListClampsPagingParams(t *testing.T) { service := newTestService() for index := 0; index < 25; index++ { - _, err := service.StartTask(map[string]any{ + _, err := startTaskForTest(service, map[string]any{ "session_id": fmt.Sprintf("sess_clamp_%02d", index), "source": "floating_ball", "trigger": "hover_text_input", @@ -6329,7 +6328,7 @@ func TestServiceTaskListFallbackMatchesRuntimeSortTieBreaker(t *testing.T) { func TestServiceDashboardOverviewUsesRuntimeAggregation(t *testing.T) { service := newTestService() - completedResult, err := service.StartTask(map[string]any{ + completedResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -6348,7 +6347,7 @@ func TestServiceDashboardOverviewUsesRuntimeAggregation(t *testing.T) { t.Fatalf("start completed task failed: %v", err) } - waitingResult, err := service.StartTask(map[string]any{ + waitingResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -6512,7 +6511,7 @@ func TestIsWorkspaceRelativePathKeepsArtifactsInsideWorkspaceScope(t *testing.T) func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_detail", "source": "floating_ball", "trigger": "hover_text_input", @@ -6532,7 +6531,7 @@ func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { } taskID := startResult["task"].(map[string]any)["task_id"].(string) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6560,7 +6559,7 @@ func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_detail_stale", "source": "floating_ball", "trigger": "hover_text_input", @@ -6592,7 +6591,7 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi runtimeRecord.SecuritySummary["pending_authorizations"] = 1 }) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6609,7 +6608,7 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_detail_bad_task", "source": "floating_ball", "trigger": "hover_text_input", @@ -6633,7 +6632,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6650,7 +6649,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing. func TestServiceTaskDetailGetDropsApprovalAnchorWhenStatusIsNotPending(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_detail_bad_status", "source": "floating_ball", "trigger": "hover_text_input", @@ -6674,7 +6673,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWhenStatusIsNotPending(t *testin runtimeRecord.ApprovalRequest["status"] = "approved" }) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6688,7 +6687,7 @@ 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{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_detail_runtime_summary", "source": "floating_ball", "trigger": "hover_text_input", @@ -6739,7 +6738,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6769,7 +6768,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{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_detail_runtime_scope", "source": "floating_ball", "trigger": "hover_text_input", @@ -6786,7 +6785,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6844,7 +6843,7 @@ func TestServiceTaskDetailGetIncludesFailureSummaryForFailedScreenTask(t *testin }, }, tools.ErrOCRWorkerFailed) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": updatedTask.TaskID}) + detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": updatedTask.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -7139,7 +7138,7 @@ func TestServiceDashboardOverviewRespectsIncludeFilter(t *testing.T) { func TestServiceDashboardOverviewFocusModeNarrowsSecondaryData(t *testing.T) { service := newTestService() - _, err := service.StartTask(map[string]any{ + _, err := startTaskForTest(service, map[string]any{ "session_id": "sess_focus", "source": "floating_ball", "trigger": "hover_text_input", @@ -7158,7 +7157,7 @@ func TestServiceDashboardOverviewFocusModeNarrowsSecondaryData(t *testing.T) { t.Fatalf("start completed task failed: %v", err) } - _, err = service.StartTask(map[string]any{ + _, err = startTaskForTest(service, map[string]any{ "session_id": "sess_focus", "source": "floating_ball", "trigger": "hover_text_input", @@ -7293,7 +7292,7 @@ func TestServiceDashboardOverviewResortsMergedRuntimeAndStoredTasks(t *testing.T t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ + runtimeResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_merge_overview", "source": "floating_ball", "trigger": "hover_text_input", @@ -7376,7 +7375,7 @@ func TestServiceDashboardOverviewLoadsStoredTasksOncePerRequest(t *testing.T) { countingStore := &countingTaskStore{base: service.storage.TaskStore()} replaceTaskStore(t, service.storage, countingStore) - if _, err := service.StartTask(map[string]any{ + if _, err := startTaskForTest(service, map[string]any{ "session_id": "sess_overview_count", "source": "floating_ball", "trigger": "hover_text_input", @@ -7428,7 +7427,7 @@ func TestServiceDashboardOverviewLoadsStoredTasksOncePerRequest(t *testing.T) { func TestServiceMirrorOverviewUsesRuntimeMirrorReferences(t *testing.T) { service := newTestService() - _, err := service.StartTask(map[string]any{ + _, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -7477,7 +7476,7 @@ func TestServiceStartTaskWritesRealMemorySummary(t *testing.T) { t.Fatal("expected storage service to be wired") } - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_memory_write", "source": "floating_ball", "trigger": "hover_text_input", @@ -7521,7 +7520,7 @@ 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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_task", "source": "floating_ball", "trigger": "hover_text_input", @@ -7597,7 +7596,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, map[string]any{"task_id": task["task_id"]}) if err != nil { t.Fatalf("task detail get for screen task failed: %v", err) } @@ -7767,7 +7766,7 @@ func TestServiceStartTaskPreservesClipCaptureModeThroughScreenApproval(t *testin t.Fatalf("write clip input failed: %v", err) } - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_clip_task", "source": "floating_ball", "trigger": "hover_text_input", @@ -7823,7 +7822,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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_infer_start", "source": "floating_ball", "trigger": "hover_text_input", @@ -7911,7 +7910,7 @@ func TestServiceStartTaskExplicitScreenAnalyzeKeepsFreshAuthorizationBoundary(t }, }) - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "hover_text_input", @@ -7985,7 +7984,7 @@ func TestServiceStartTaskHandlesClipScreenAnalyzePath(t *testing.T) { t.Fatalf("write clip source failed: %v", err) } - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_clip_start", "source": "floating_ball", "trigger": "hover_text_input", @@ -8034,7 +8033,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get for clip screen task failed: %v", err) } @@ -8058,7 +8057,7 @@ 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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_stop", "source": "floating_ball", "trigger": "hover_text_input", @@ -8110,7 +8109,7 @@ 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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_cleanup", "source": "floating_ball", "trigger": "hover_text_input", @@ -8160,7 +8159,7 @@ 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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_capture_failure", "source": "floating_ball", "trigger": "hover_text_input", @@ -8207,7 +8206,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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_summary_title", "source": "floating_ball", "trigger": "hover_text_input", @@ -8241,7 +8240,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{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_screen_infer_submit", "source": "floating_ball", "trigger": "hover_text_input", @@ -8277,7 +8276,7 @@ func TestServiceSubmitInputInfersScreenAnalyzeFromVisualErrorRequest(t *testing. func TestServiceStartTaskFallsBackWhenScreenCapabilityUnavailable(t *testing.T) { service := newTestService() - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_capability_fallback", "source": "floating_ball", "trigger": "hover_text_input", @@ -8316,7 +8315,7 @@ func TestServiceStartTaskFallsBackWhenScreenCapabilityUnavailable(t *testing.T) func TestServiceSubmitInputFallbackKeepsExplicitConfirmationWhenScreenCapabilityUnavailable(t *testing.T) { service := newTestService() - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_screen_capability_confirm_fallback", "source": "floating_ball", "trigger": "hover_text_input", @@ -8363,7 +8362,7 @@ 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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_screen_fail", "source": "floating_ball", "trigger": "hover_text_input", @@ -8421,7 +8420,7 @@ func TestServiceStartTaskHitsRealMemoryAndRecordsRetrievalHit(t *testing.T) { t.Fatalf("seed memory summary failed: %v", err) } - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_memory_hit", "source": "floating_ball", "trigger": "hover_text_input", @@ -8515,7 +8514,7 @@ func TestServiceStartTaskInjectsRetrievedMemoryIntoExecutionInput(t *testing.T) t.Fatalf("seed memory summary failed: %v", err) } - _, err := service.StartTask(map[string]any{ + _, err := startTaskForTest(service, map[string]any{ "session_id": "sess_memory_context", "source": "floating_ball", "trigger": "hover_text_input", @@ -8580,7 +8579,7 @@ func TestServiceConfirmTaskPersistsRetrievalHitOncePerConfirmation(t *testing.T) t.Fatalf("seed memory summary failed: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_confirm_memory_hit", "source": "floating_ball", "trigger": "text_selected_click", @@ -8666,7 +8665,7 @@ func TestServiceMirrorOverviewFallsBackToStoredFinishedTasks(t *testing.T) { func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { service := newTestService() - _, err := service.StartTask(map[string]any{ + _, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -8685,7 +8684,7 @@ func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { t.Fatalf("start completed task failed: %v", err) } - waitingResult, err := service.StartTask(map[string]any{ + waitingResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -8740,7 +8739,7 @@ func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { func TestServiceSecuritySummaryIncludesRuntimeTokenUsage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "executor-backed token summary") - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -8797,7 +8796,7 @@ func TestServiceBudgetAutoDowngradeSwitchesWorkspaceDeliveryToBubble(t *testing. }) defer unsubscribe() - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_budget_downgrade", "source": "floating_ball", "trigger": "hover_text_input", @@ -8964,7 +8963,7 @@ func TestServiceBudgetAutoDowngradeDisabledKeepsWorkspaceDelivery(t *testing.T) t.Fatalf("settings update failed: %v", err) } - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_budget_disabled", "source": "floating_ball", "trigger": "hover_text_input", @@ -9109,7 +9108,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{ + result, err := submitInputForTest(service, map[string]any{ "session_id": "sess_budget_fallback_signal", "source": "floating_ball", "trigger": "hover_text_input", @@ -9331,7 +9330,7 @@ func TestServiceDashboardModuleCountsRuntimeAndStoredPendingAuthorizations(t *te t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ + runtimeResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_dashboard_module_mixed", "source": "floating_ball", "trigger": "hover_text_input", @@ -9402,7 +9401,7 @@ func TestServiceSecuritySummaryCountsRuntimeAndStoredPendingAuthorizations(t *te t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ + runtimeResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_security_summary_mixed", "source": "floating_ball", "trigger": "hover_text_input", @@ -9466,7 +9465,7 @@ func TestServiceSecurityPendingListMergesRuntimeAndStoredPendingAuthorizations(t t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ + runtimeResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_security_pending_list_mixed", "source": "floating_ball", "trigger": "hover_text_input", @@ -9547,7 +9546,7 @@ func TestServiceSecurityPendingListPaginatesMergedPendingAuthorizations(t *testi t.Fatal("expected storage service to be wired") } - runtimeResult, err := service.StartTask(map[string]any{ + runtimeResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_security_pending_list_page", "source": "floating_ball", "trigger": "hover_text_input", @@ -9772,7 +9771,7 @@ func TestServiceSecurityRestoreApplyReturnsStorageErrorWhenApprovalPersistenceFa t.Fatalf("seed output file: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_restore_store_failure", "source": "floating_ball", "trigger": "hover_text_input", @@ -9813,7 +9812,7 @@ func TestServiceSecurityRestoreApplyReturnsStorageErrorWhenApprovalPersistenceFa func TestServiceDashboardModuleHighlightsIncludeAuditTrail(t *testing.T) { service, _ := newTestServiceWithExecution(t, "dashboard audit trail") - _, err := service.StartTask(map[string]any{ + _, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -10114,7 +10113,7 @@ func TestServiceTaskDetailGetFallsBackToStoredRecoveryPointForTask(t *testing.T) t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "text_selected_click", @@ -10138,7 +10137,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10155,7 +10154,7 @@ func TestServiceTaskDetailGetDropsMismatchedSummaryRecoveryPointBeforeStorageFal t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec_mismatch", "source": "floating_ball", "trigger": "text_selected_click", @@ -10187,7 +10186,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10208,7 +10207,7 @@ func TestServiceSecurityRestoreApplyRestoresWorkspaceAndReturnsFormalResult(t *t t.Fatalf("seed original file: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -10328,7 +10327,7 @@ func TestServiceSecurityRestoreApplyReturnsStructuredFailure(t *testing.T) { t.Fatalf("seed original file: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -10420,7 +10419,7 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) t.Fatalf("seed original file: %v", err) } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -10446,7 +10445,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, map[string]any{"task_id": 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}) @@ -10478,7 +10477,7 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) func TestServiceTaskDetailGetPreservesStableContractShape(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail delivery") - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_detail", "source": "floating_ball", "trigger": "hover_text_input", @@ -10492,7 +10491,7 @@ func TestServiceTaskDetailGetPreservesStableContractShape(t *testing.T) { } taskID := startResult["task"].(map[string]any)["task_id"].(string) - detailResult, err := service.TaskDetailGet(map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10526,7 +10525,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{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_detail_protocol", "source": "floating_ball", "trigger": "hover_text_input", @@ -10566,7 +10565,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10656,7 +10655,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, map[string]any{"task_id": "task_stored_detail"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10731,7 +10730,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, map[string]any{"task_id": "task_stored_screen_detail"}) if err != nil { t.Fatalf("task detail get with stored citations failed: %v", err) } @@ -10780,7 +10779,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, map[string]any{"task_id": "task_structured_detail"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10844,7 +10843,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, map[string]any{"task_id": "task_structured_snapshot"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10925,7 +10924,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, map[string]any{"task_id": "task_structured_invalid_snapshot"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11032,7 +11031,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, map[string]any{"task_id": "task_structured_session_run"}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11082,7 +11081,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, map[string]any{"task_id": runtimeTask.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11193,7 +11192,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11269,7 +11268,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11349,7 +11348,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11470,7 +11469,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11551,7 +11550,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11683,7 +11682,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11781,7 +11780,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11868,7 +11867,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12022,7 +12021,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, map[string]any{"task_id": runtimeTask.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12093,7 +12092,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, map[string]any{"task_id": runtimeTask.TaskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12247,7 +12246,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12450,7 +12449,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12541,7 +12540,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12620,7 +12619,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12923,7 +12922,7 @@ func TestServiceTaskArtifactOpenPrefersStoredArtifactOverRuntimeCompatibility(t func TestServiceTaskArtifactOpenReturnsArtifactNotFoundWhenTaskExists(t *testing.T) { service, _ := newTestServiceWithExecution(t, "artifact not found") - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_artifact_not_found", "source": "floating_ball", "trigger": "hover_text_input", @@ -12953,7 +12952,7 @@ func TestServiceStartTaskPersistsArtifactsToStore(t *testing.T) { if service.storage == nil { t.Fatal("expected storage service to be wired") } - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_artifact_persist", "source": "floating_ball", "trigger": "hover_text_input", @@ -12991,7 +12990,7 @@ func TestServiceStartTaskPersistsArtifactsToStore(t *testing.T) { func TestServiceDeliveryOpenReturnsTaskDeliveryResult(t *testing.T) { service, _ := newTestServiceWithExecution(t, "delivery open task") - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_delivery_open", "source": "floating_ball", "trigger": "hover_text_input", @@ -13143,7 +13142,7 @@ func TestTaskArtifactHelpersCoverFallbackBranches(t *testing.T) { func TestServiceTaskArtifactListFallsBackToRuntimeArtifactsWhenStoreEmpty(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_runtime_artifact", "source": "floating_ball", "trigger": "hover_text_input", @@ -13194,7 +13193,7 @@ 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{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_merge_artifact", "source": "floating_ball", "trigger": "hover_text_input", @@ -13280,7 +13279,7 @@ func TestServiceTaskArtifactListMergesStoredAndRuntimeArtifacts(t *testing.T) { func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_runtime_missing_artifact_id", "source": "floating_ball", "trigger": "hover_text_input", @@ -13308,7 +13307,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13342,7 +13341,7 @@ func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t * func TestServiceTaskControlRejectsInvalidStatusTransition(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -14104,7 +14103,7 @@ func TestDashboardModuleGetIncludesPluginRuntimeSummary(t *testing.T) { func TestDashboardModuleGetTasksIncludesFocusRuntimeSummary(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_dashboard_tasks_runtime", "source": "floating_ball", "trigger": "hover_text_input", @@ -14177,7 +14176,7 @@ func TestServiceTaskControlRequiresTaskID(t *testing.T) { func TestServiceTaskControlRequiresAction(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -14208,7 +14207,7 @@ func TestServiceTaskControlRequiresAction(t *testing.T) { func TestServiceTaskControlRejectsFinishedTaskOperations(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -14240,7 +14239,7 @@ func TestServiceTaskControlRejectsFinishedTaskOperations(t *testing.T) { func TestServiceTaskControlReturnsUpdatedTaskAndBubbleForWaitingAuthCancel(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_task_control_payload", "source": "floating_ball", "trigger": "hover_text_input", @@ -14599,7 +14598,7 @@ func TestPersistExecutionToolCallEventsFallsBackWhenToolCallIDMissing(t *testing func TestServiceTaskSteerPersistsFollowUpMessage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task steer") - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_task_steer", "source": "floating_ball", "trigger": "hover_text_input", @@ -14835,7 +14834,7 @@ func TestServiceSubmitInputRoutesFollowUpIntoExistingTask(t *testing.T) { activeTaskID = activeTask.TaskID activeSessionID := activeTask.SessionID - followUpResult, err := service.SubmitInput(map[string]any{ + followUpResult, err := submitInputForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -14876,7 +14875,7 @@ func TestServiceSubmitInputQueuesNewTaskWhenActivePromptTaskCannotConsumeSteerin RiskLevel: "green", }) - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "hover_text_input", @@ -14909,7 +14908,7 @@ func TestServiceSubmitInputQueuesNewTaskWhenActivePromptTaskCannotConsumeSteerin func TestServiceStartTaskNotificationIncludesSessionID(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": "sess_notification_contract", "source": "floating_ball", "trigger": "hover_text_input", @@ -14973,7 +14972,7 @@ func TestServiceStartTaskDescribedFileDoesNotAttachToProcessingTask(t *testing.T }) activeTaskID = activeTask.TaskID - followUpResult, err := service.StartTask(map[string]any{ + followUpResult, err := startTaskForTest(service, map[string]any{ "source": "floating_ball", "trigger": "file_drop", "input": map[string]any{ @@ -15022,7 +15021,7 @@ func TestServiceStartTaskDescribedFileStartsNewTaskWithoutPendingEvidence(t *tes }, }) - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": waitingTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15073,7 +15072,7 @@ func TestServiceStartTaskShellBallAnchorDoesNotContinuePendingFileTask(t *testin }, }) - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": waitingTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15170,7 +15169,7 @@ func TestServiceStartTaskConfirmRequiredFileDoesNotContinueProcessingTask(t *tes }) activeTaskID = activeTask.TaskID - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15230,7 +15229,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesPendingTask(t *testing.T) RiskLevel: "green", }) - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "hover_text_input", @@ -15290,7 +15289,7 @@ func TestServiceSubmitInputFallsBackWhenContinuationModelTimesOut(t *testing.T) }) start := time.Now() - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -15351,7 +15350,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesImplicitPendingTask(t *te }, }) - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -15429,7 +15428,7 @@ func TestServiceSubmitInputPlainTextKeepsConfirmingTaskBehindConfirmation(t *tes }, }) - result, err := service.SubmitInput(map[string]any{ + result, err := submitInputForTest(service, map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "hover_text_input", @@ -15513,7 +15512,7 @@ func TestServiceStartTaskPlainTextImplicitPendingTaskStartsNewWithoutExplicitCon }) activeTaskID = activeTask.TaskID - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -15644,7 +15643,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesWaitingInputTask(t *testing }) activeTaskID = activeTask.TaskID - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15721,7 +15720,7 @@ func TestServiceStartTaskStructuredSupplementContinuesPendingTaskWithoutAutoExec }, }) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15783,7 +15782,7 @@ func TestServiceStartTaskStructuredSupplementResumesWaitingTaskWithConfirmedInte }, }) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15869,7 +15868,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesUniquePendingTaskAmongCandi }, }) - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": targetTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15944,7 +15943,7 @@ func TestServiceStartTaskConfirmRequiredFileStartsNewTaskWithoutPendingEvidence( }) activeTaskID = activeTask.TaskID - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15986,7 +15985,7 @@ func TestServiceStartTaskConfirmRequiredFileStartsNewTaskWithoutPendingEvidence( func TestServiceSubmitInputDoesNotContinueWaitingAuthorizationTask(t *testing.T) { service := newTestService() - startResult, err := service.StartTask(map[string]any{ + startResult, err := startTaskForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16005,7 +16004,7 @@ func TestServiceSubmitInputDoesNotContinueWaitingAuthorizationTask(t *testing.T) } firstTask := startResult["task"].(map[string]any) - followUpResult, err := service.SubmitInput(map[string]any{ + followUpResult, err := submitInputForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16042,7 +16041,7 @@ func TestServiceSubmitInputDoesNotContinuePausedTask(t *testing.T) { t.Fatalf("pause task failed: %v", err) } - followUpResult, err := service.SubmitInput(map[string]any{ + followUpResult, err := submitInputForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16073,7 +16072,7 @@ func TestServiceStartTaskWithExplicitIntentDoesNotReuseWaitingTaskWithoutAnchors RiskLevel: "green", }) - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16116,7 +16115,7 @@ func TestServiceSubmitInputStartsNewTaskForUnrelatedRequest(t *testing.T) { }, }) - firstResult, err := service.StartTask(map[string]any{ + firstResult, err := startTaskForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16135,7 +16134,7 @@ func TestServiceSubmitInputStartsNewTaskForUnrelatedRequest(t *testing.T) { } firstTask := firstResult["task"].(map[string]any) - secondResult, err := service.SubmitInput(map[string]any{ + secondResult, err := submitInputForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16167,7 +16166,7 @@ func TestServiceReusesRecentIdleSessionForNewTopLevelTask(t *testing.T) { RiskLevel: "green", }) - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16193,7 +16192,7 @@ func TestServiceReusesRecentIdleSessionForNewTopLevelTask(t *testing.T) { func TestServiceTaskListIncludesLoopStopReason(t *testing.T) { service := newTestService() for index := 0; index < 2; index++ { - _, err := service.StartTask(map[string]any{ + _, err := startTaskForTest(service, map[string]any{ "session_id": fmt.Sprintf("sess_loop_stop_%02d", index), "source": "floating_ball", "trigger": "hover_text_input", @@ -16515,7 +16514,7 @@ func TestServiceTaskListDoesNotDoubleCountRuntimeTasksBackedByLegacyRows(t *test func TestServiceStartTaskWithExecutorWritesWorkspaceDocument(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "第一点\n第二点\n第三点") - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -16577,7 +16576,7 @@ func TestServiceStartTaskWithExecutorWritesWorkspaceDocument(t *testing.T) { func TestServiceStartTaskWithExecutorReturnsGeneratedBubble(t *testing.T) { service, _ := newTestServiceWithExecution(t, "这段内容主要在解释当前问题的原因和处理方向。") - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -16628,7 +16627,7 @@ func TestServiceStartTaskWithExecutorDeliversPageReadBubble(t *testing.T) { Source: "playwright_sidecar", }}) - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_page_read", "source": "floating_ball", "trigger": "hover_text_input", @@ -16693,7 +16692,7 @@ func TestServiceStartTaskWithExecutorDeliversPageSearchBubble(t *testing.T) { Source: "playwright_sidecar", }}) - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_page_search", "source": "floating_ball", "trigger": "hover_text_input", @@ -16744,7 +16743,7 @@ func TestServiceStartTaskWithExecutorDeliversPageSearchBubble(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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_ocr_extract", "source": "floating_ball", "trigger": "hover_text_input", @@ -16793,7 +16792,7 @@ 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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_media_transcode", "source": "floating_ball", "trigger": "hover_text_input", @@ -16848,7 +16847,7 @@ 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{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_page_read_fail", "source": "floating_ball", "trigger": "hover_text_input", @@ -16907,7 +16906,7 @@ func TestServiceStartTaskWithRealLocalPageReadDelivery(t *testing.T) { }() service, _ := newTestServiceWithExecutionAndPlaywright(t, "unused", platform.LocalExecutionBackend{}, nil, localHTTPPlaywrightClient{}) - result, err := service.StartTask(map[string]any{ + result, err := startTaskForTest(service, map[string]any{ "session_id": "sess_real_page_read", "source": "floating_ball", "trigger": "hover_text_input", diff --git a/services/local-service/internal/orchestrator/task_query.go b/services/local-service/internal/orchestrator/task_query.go index 0866ec677..1493f7f26 100644 --- a/services/local-service/internal/orchestrator/task_query.go +++ b/services/local-service/internal/orchestrator/task_query.go @@ -38,7 +38,15 @@ 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) { +func (s *Service) TaskDetailGet(request TaskDetailGetRequest) (TaskDetailGetResponse, error) { + response, err := s.taskDetailGet(request.paramsMap()) + if err != nil { + return TaskDetailGetResponse{}, err + } + return newTaskDetailGetResponse(response), nil +} + +func (s *Service) taskDetailGet(params map[string]any) (map[string]any, error) { taskID := stringValue(params, "task_id", "") task, ok := s.taskDetailFromStorage(taskID) if runtimeTask, runtimeOK := s.runEngine.TaskDetail(taskID); runtimeOK { diff --git a/services/local-service/internal/orchestrator/task_start.go b/services/local-service/internal/orchestrator/task_start.go index 391510db2..df4302413 100644 --- a/services/local-service/internal/orchestrator/task_start.go +++ b/services/local-service/internal/orchestrator/task_start.go @@ -12,7 +12,15 @@ 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) { + response, err := s.startTask(request.paramsMap()) + if err != nil { + return TaskEntryResponse{}, err + } + return newTaskEntryResponse(response), nil +} + +func (s *Service) startTask(params map[string]any) (map[string]any, error) { flow := s.prepareStartTaskFlow(params) if response, handled, err := s.maybeContinueStartTask(&flow); err != nil || handled { return response, err diff --git a/services/local-service/internal/rpc/handlers.go b/services/local-service/internal/rpc/handlers.go index 0b4b5cf1f..13aff6a31 100644 --- a/services/local-service/internal/rpc/handlers.go +++ b/services/local-service/internal/rpc/handlers.go @@ -1,6 +1,8 @@ // Package rpc routes stable JSON-RPC methods into the main orchestrator. package rpc +import "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" + // registerHandlers binds stable agent.* JSON-RPC methods to their protocol // decoders and orchestrator entry points. func (s *Server) registerHandlers() { @@ -32,13 +34,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.SubmitInput(orchestrator.SubmitInputRequestFromParams(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.StartTask(orchestrator.StartTaskRequestFromParams(params)) return wrapOrchestratorResult(data, err) } @@ -69,7 +71,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.TaskDetailGet(orchestrator.TaskDetailGetRequestFromParams(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..3fa6f7a16 100644 --- a/services/local-service/internal/rpc/jsonrpc.go +++ b/services/local-service/internal/rpc/jsonrpc.go @@ -117,7 +117,7 @@ func newSuccessEnvelope(id json.RawMessage, data any, serverTime string) success JSONRPC: "2.0", ID: normalizeID(id), Result: resultEnvelope{ - Data: data, + Data: protocolResultData(data), Meta: responseMeta{ ServerTime: serverTime, }, @@ -126,6 +126,13 @@ func newSuccessEnvelope(id json.RawMessage, data any, serverTime string) success } } +func protocolResultData(data any) any { + if mappable, ok := data.(interface{ Map() map[string]any }); ok { + return mappable.Map() + } + return data +} + // newErrorEnvelope assembles the shared error response format. func newErrorEnvelope(id json.RawMessage, rpcErr *rpcError) errorEnvelope { var envelope errorEnvelope diff --git a/services/local-service/internal/rpc/notifications.go b/services/local-service/internal/rpc/notifications.go index 44fad071a..61c8c0ca4 100644 --- a/services/local-service/internal/rpc/notifications.go +++ b/services/local-service/internal/rpc/notifications.go @@ -139,6 +139,8 @@ func normalizeNotificationKey(method, taskID string, params map[string]any) map[ // a task_id suffix. func collectTaskIDs(rawValue any, ids map[string]struct{}) { switch value := rawValue.(type) { + case interface{ Map() map[string]any }: + collectTaskIDs(value.Map(), ids) case map[string]any: for key, item := range value { if strings.HasSuffix(key, "task_id") { diff --git a/services/local-service/internal/rpc/protocol_dto_test_helpers_test.go b/services/local-service/internal/rpc/protocol_dto_test_helpers_test.go new file mode 100644 index 000000000..b8bb7053d --- /dev/null +++ b/services/local-service/internal/rpc/protocol_dto_test_helpers_test.go @@ -0,0 +1,27 @@ +package rpc + +import "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" + +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 submitInputForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { + response, err := s.SubmitInput(orchestrator.SubmitInputRequestFromParams(params)) + if err != nil { + return nil, err + } + return response.Map(), nil +} + +func taskDetailGetForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { + response, err := s.TaskDetailGet(orchestrator.TaskDetailGetRequestFromParams(params)) + if err != nil { + return nil, err + } + return response.Map(), nil +} diff --git a/services/local-service/internal/rpc/server_test.go b/services/local-service/internal/rpc/server_test.go index 382223d8d..4516b3f68 100644 --- a/services/local-service/internal/rpc/server_test.go +++ b/services/local-service/internal/rpc/server_test.go @@ -139,7 +139,7 @@ func (a testStorageAdapter) SecretStorePath() string { // 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", @@ -215,7 +215,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", @@ -298,7 +298,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", @@ -575,7 +575,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", @@ -1426,7 +1426,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", @@ -1551,7 +1551,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", @@ -1696,7 +1696,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", @@ -1839,7 +1839,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", @@ -2065,7 +2065,7 @@ func TestDispatchTaskStartFileInstructionSkipsIntentConfirmation(t *testing.T) { // 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", @@ -2150,7 +2150,7 @@ func TestHandleHTTPRPCRejectsNonLoopbackOrigins(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", @@ -2204,7 +2204,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", @@ -2257,7 +2257,7 @@ func TestDispatchTaskDetailGetOmitsApprovalAnchorForCompletedTask(t *testing.T) func TestDispatchMapsTaskControlStatusErrors(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", @@ -2293,7 +2293,7 @@ func TestDispatchMapsTaskControlStatusErrors(t *testing.T) { func TestDispatchMapsTaskControlFinishedErrors(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": "hover_text_input", @@ -2423,7 +2423,7 @@ func TestDispatchReturnsSecurityRestoreApplyResult(t *testing.T) { storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "restore-apply.db"))) defer func() { _ = storageService.Close() }() server.orchestrator.WithStorage(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", @@ -2636,7 +2636,7 @@ func TestDispatchReturnsDeliveryOpenForArtifact(t *testing.T) { 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", @@ -3066,7 +3066,7 @@ func TestDispatchReturnsPluginDetail(t *testing.T) { func TestDispatchMapsTaskControlInvalidActionToInvalidParams(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", @@ -3123,7 +3123,7 @@ func TestDispatchMapsTaskControlMissingTaskIDToInvalidParams(t *testing.T) { func TestDispatchMapsTaskControlMissingActionToInvalidParams(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": "hover_text_input", @@ -3165,7 +3165,7 @@ 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", @@ -3306,7 +3306,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", diff --git a/services/local-service/internal/rpc/server_transport_test.go b/services/local-service/internal/rpc/server_transport_test.go index 1392ef545..dfe0c1da4 100644 --- a/services/local-service/internal/rpc/server_transport_test.go +++ b/services/local-service/internal/rpc/server_transport_test.go @@ -269,7 +269,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", @@ -331,7 +331,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", From 935ce8f4d4a771f8e88f1ef5e719f4016f4f6d14 Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sun, 10 May 2026 02:43:54 +0800 Subject: [PATCH 07/41] refactor(orchestrator): type screen approval state --- .../internal/orchestrator/approval_state.go | 7 +- .../internal/orchestrator/screen_analyze.go | 47 ++++---- .../orchestrator/screen_approval_state.go | 103 ++++++++++++++++++ .../screen_approval_state_test.go | 87 +++++++++++++++ .../internal/orchestrator/service_test.go | 36 +++--- 5 files changed, 240 insertions(+), 40 deletions(-) create mode 100644 services/local-service/internal/orchestrator/screen_approval_state.go create mode 100644 services/local-service/internal/orchestrator/screen_approval_state_test.go diff --git a/services/local-service/internal/orchestrator/approval_state.go b/services/local-service/internal/orchestrator/approval_state.go index fed000bee..f870ce3cf 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) 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/screen_analyze.go b/services/local-service/internal/orchestrator/screen_analyze.go index 4686a83b2..b1447bbf5 100644 --- a/services/local-service/internal/orchestrator/screen_analyze.go +++ b/services/local-service/internal/orchestrator/screen_analyze.go @@ -43,15 +43,18 @@ func (s *Service) handleScreenAnalyzeStart(params map[string]any, snapshot conte "delivery_result": nil, }, true, nil } - approvalRequest, pendingExecution, bubble, err := s.buildScreenAnalysisApprovalState(task) + approvalState, err := s.buildScreenAnalysisApprovalState(task) if err != nil { return nil, 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 } - 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 nil, false, err } return map[string]any{ @@ -100,7 +103,7 @@ func inferredScreenFallbackSubject(snapshot contextsvc.TaskContextSnapshot) stri // 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) @@ -112,28 +115,28 @@ 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": "屏幕分析结果", - "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: "屏幕分析结果", + PreviewText: screenAnalysisPreviewText(captureMode), + ImpactScope: screenAnalysisImpactScope{ + Files: impactFilesForScreenTarget(sourcePath), + Webpages: []string{}, + Apps: []string{}, + OutOfWorkspace: false, + OverwriteOrDeleteRisk: false, }, } bubble := s.delivery.BuildBubbleMessage(task.TaskID, "status", "屏幕截图分析属于敏感能力,请先确认授权。", task.UpdatedAt.Format(dateTimeLayout)) - return approvalRequest, pendingExecution, bubble, nil + return newScreenAnalysisApprovalState(approvalRequest, pendingExecution, bubble), nil } func (s *Service) resolveScreenAnalyzeIntent(snapshot contextsvc.TaskContextSnapshot, current map[string]any) map[string]any { 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..6bf7173e5 --- /dev/null +++ b/services/local-service/internal/orchestrator/screen_approval_state.go @@ -0,0 +1,103 @@ +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 { + var approval ApprovalRequestDTO + decodeProtocolMap(approvalRequest, &approval) + var bubbleMessage BubbleMessageDTO + decodeProtocolMap(bubble, &bubbleMessage) + return screenAnalysisApprovalState{ + ApprovalRequest: approval, + PendingExecution: pendingExecution, + BubbleMessage: bubbleMessage, + } +} + +func (state screenAnalysisApprovalState) approvalRequestMap() map[string]any { + return map[string]any{ + "approval_id": state.ApprovalRequest.ApprovalID, + "task_id": state.ApprovalRequest.TaskID, + "operation_name": state.ApprovalRequest.OperationName, + "risk_level": state.ApprovalRequest.RiskLevel, + "target_object": state.ApprovalRequest.TargetObject, + "reason": state.ApprovalRequest.Reason, + "status": state.ApprovalRequest.Status, + "created_at": state.ApprovalRequest.CreatedAt, + } +} + +func (state screenAnalysisApprovalState) pendingExecutionMap() map[string]any { + return map[string]any{ + "kind": state.PendingExecution.Kind, + "operation_name": state.PendingExecution.OperationName, + "source_path": state.PendingExecution.SourcePath, + "capture_mode": state.PendingExecution.CaptureMode, + "source": state.PendingExecution.Source, + "target_object": state.PendingExecution.TargetObject, + "language": state.PendingExecution.Language, + "evidence_role": state.PendingExecution.EvidenceRole, + "delivery_type": state.PendingExecution.DeliveryType, + "result_title": state.PendingExecution.ResultTitle, + "preview_text": state.PendingExecution.PreviewText, + "impact_scope": state.PendingExecution.ImpactScope.mapValue(), + } +} + +func (state screenAnalysisApprovalState) bubbleMessageMap() map[string]any { + return map[string]any{ + "bubble_id": state.BubbleMessage.BubbleID, + "task_id": state.BubbleMessage.TaskID, + "type": state.BubbleMessage.Type, + "text": state.BubbleMessage.Text, + "pinned": state.BubbleMessage.Pinned, + "hidden": state.BubbleMessage.Hidden, + "created_at": state.BubbleMessage.CreatedAt, + } +} + +func (scope screenAnalysisImpactScope) mapValue() map[string]any { + return map[string]any{ + "files": cloneScreenAnalysisStrings(scope.Files), + "webpages": cloneScreenAnalysisStrings(scope.Webpages), + "apps": cloneScreenAnalysisStrings(scope.Apps), + "out_of_workspace": scope.OutOfWorkspace, + "overwrite_or_delete_risk": 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 d85b75b09..2c35873c1 100644 --- a/services/local-service/internal/orchestrator/service_test.go +++ b/services/local-service/internal/orchestrator/service_test.go @@ -5146,23 +5146,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) } @@ -7652,22 +7656,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) } @@ -7714,20 +7720,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") } @@ -7739,7 +7743,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" { From 2ad95a0f370e3df259525aee0b59e623ff00cba4 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 02:09:32 +0000 Subject: [PATCH 08/41] fix(local-service): enforce typed rpc dto boundaries Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- scripts/ci/README.md | 23 +- .../orchestrator/protocol_request_dto.go | 6 + .../orchestrator/protocol_response_dto.go | 12 + .../local-service/internal/rpc/entry_dto.go | 234 +++++++++++++++++- .../local-service/internal/rpc/methods.go | 2 +- .../internal/rpc/protocol_registry_test.go | 59 +++++ .../local-service/internal/rpc/server_test.go | 32 ++- .../internal/rpc/server_transport_test.go | 8 +- 8 files changed, 351 insertions(+), 25 deletions(-) diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 85fa5d3d9..63f418555 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -15,17 +15,18 @@ go test ./services/local-service/... ``` `local-service-style` verifies that `goimports` has been applied to changed Go -files under `services/local-service`, rejects newly added Chinese Go comments in -local-service diffs, and scans all current `services/local-service` Go comments -so historical comment debt cannot silently return, and enforces a 2,000-line -ceiling for changed non-test Go files. The file-size guard only reports files -that both exceed the ceiling and grow relative to the base or local pre-change -snapshot, so historical oversized files can still receive focused fixes without -making the size debt worse. In pull requests, CI passes the base SHA so -added-comment diagnostics still point at the PR diff. In local mode, the -added-comment guard evaluates staged hunks against the index snapshot and -unstaged hunks against the working tree, so partial staging in the same file does -not skew reported line numbers. +files under `services/local-service` and to the Go-based checker itself under +`scripts/ci`, rejects newly added Chinese Go comments in local-service diffs, +and scans all current `services/local-service` Go comments so historical comment +debt cannot silently return, and enforces a 2,000-line ceiling for changed +non-test Go files. The file-size guard only reports files that both exceed the +ceiling and grow relative to the base or local pre-change snapshot, so +historical oversized files can still receive focused fixes without making the +size debt worse. In pull requests, CI passes the base SHA so added-comment +diagnostics still point at the PR diff. In local mode, the added-comment guard +evaluates staged hunks against the index snapshot and unstaged hunks against the +working tree, so partial staging in the same file does not skew reported line +numbers. The repository pins `goimports` and `staticcheck` through the root [`go.mod`](../go.mod), so local runs and CI stay on the same tool versions until the module is diff --git a/services/local-service/internal/orchestrator/protocol_request_dto.go b/services/local-service/internal/orchestrator/protocol_request_dto.go index 9642f561e..4352e9104 100644 --- a/services/local-service/internal/orchestrator/protocol_request_dto.go +++ b/services/local-service/internal/orchestrator/protocol_request_dto.go @@ -110,6 +110,8 @@ type SubmitInputRequest struct { Context *InputContext `json:"context,omitempty"` VoiceMeta *VoiceMeta `json:"voice_meta,omitempty"` Options *InputSubmitOptions `json:"options,omitempty"` + + raw map[string]any } // TaskStartInput is the formal input object for agent.task.start. @@ -145,6 +147,8 @@ type StartTaskRequest struct { Delivery *DeliveryPreference `json:"delivery,omitempty"` Options *TaskStartOptions `json:"options,omitempty"` Intent map[string]any `json:"-"` + + raw map[string]any } // TaskDetailGetRequest is the typed orchestrator boundary for @@ -152,4 +156,6 @@ type StartTaskRequest struct { type TaskDetailGetRequest struct { RequestMeta RequestMeta `json:"request_meta"` TaskID string `json:"task_id"` + + raw map[string]any } diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index 34b9ab4a9..b41562489 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -193,6 +193,7 @@ func StartTaskRequestFromParams(params map[string]any) StartTaskRequest { if intent := mapValue(params, "intent"); len(intent) > 0 { request.Intent = cloneMap(intent) } + request.raw = cloneMap(params) return request } @@ -201,6 +202,7 @@ func StartTaskRequestFromParams(params map[string]any) StartTaskRequest { func SubmitInputRequestFromParams(params map[string]any) SubmitInputRequest { var request SubmitInputRequest decodeProtocolMap(params, &request) + request.raw = cloneMap(params) return request } @@ -209,10 +211,14 @@ func SubmitInputRequestFromParams(params map[string]any) SubmitInputRequest { func TaskDetailGetRequestFromParams(params map[string]any) TaskDetailGetRequest { var request TaskDetailGetRequest decodeProtocolMap(params, &request) + request.raw = cloneMap(params) return request } func (r StartTaskRequest) paramsMap() map[string]any { + if r.raw != nil { + return cloneMap(r.raw) + } params := structToProtocolMap(r) if len(r.Intent) > 0 { params["intent"] = cloneMap(r.Intent) @@ -221,10 +227,16 @@ func (r StartTaskRequest) paramsMap() map[string]any { } func (r SubmitInputRequest) paramsMap() map[string]any { + if r.raw != nil { + return cloneMap(r.raw) + } return structToProtocolMap(r) } func (r TaskDetailGetRequest) paramsMap() map[string]any { + if r.raw != nil { + return cloneMap(r.raw) + } return structToProtocolMap(r) } diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index 82acbd8f1..adaf06313 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -5,6 +5,50 @@ import ( "encoding/json" ) +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": {}, + } + 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": {}, + } +) + // RequestMeta mirrors packages/protocol RequestMeta and carries the trace // anchor that error envelopes return to clients. type RequestMeta struct { @@ -138,21 +182,48 @@ type AgentTaskStartParams struct { Options *agentTaskStartOptions `json:"options,omitempty"` } +// AgentTaskDetailGetParams mirrors packages/protocol AgentTaskDetailGetParams +// so stable detail requests can reject incomplete task identifiers at the RPC +// boundary instead of reaching orchestrator lookups as empty strings. +type AgentTaskDetailGetParams struct { + RequestMeta RequestMeta `json:"request_meta"` + TaskID string `json:"task_id"` +} + func decodeAgentInputSubmitParams(raw json.RawMessage) (map[string]any, *rpcError) { var params AgentInputSubmitParams - return decodeTypedProtocolParams(raw, ¶ms) + return decodeTypedProtocolParams(raw, ¶ms, validateAgentInputSubmitParams) } func decodeAgentTaskStartParams(raw json.RawMessage) (map[string]any, *rpcError) { var params AgentTaskStartParams - return decodeTypedProtocolParams(raw, ¶ms) + return decodeTypedProtocolParams(raw, ¶ms, validateAgentTaskStartParams) +} + +func decodeAgentTaskDetailGetParams(raw json.RawMessage) (map[string]any, *rpcError) { + var params AgentTaskDetailGetParams + return decodeTypedProtocolParams(raw, ¶ms, validateAgentTaskDetailGetParams) } -func decodeTypedProtocolParams(raw json.RawMessage, target any) (map[string]any, *rpcError) { +func decodeTypedProtocolParams(raw json.RawMessage, target any, validate func(map[string]any) *rpcError) (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 err := json.Unmarshal(trimmed, target); err != nil { return nil, &rpcError{ Code: errInvalidParams, @@ -164,6 +235,163 @@ func decodeTypedProtocolParams(raw json.RawMessage, target any) (map[string]any, return protocolParamsMap(target) } +func validateAgentInputSubmitParams(params map[string]any) *rpcError { + 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 := requireEnumValue(input, "input_mode", inputModeSet); err != nil { + return err + } + if err := validateContextEnvelope(params); err != nil { + return err + } + options := mapObject(params, "options") + if err := optionalEnumValue(options, "preferred_delivery", deliveryTypeSet); err != nil { + return err + } + return nil +} + +func validateAgentTaskStartParams(params map[string]any) *rpcError { + 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 := validateContextEnvelope(params); err != nil { + return err + } + delivery := mapObject(params, "delivery") + if err := optionalEnumValue(delivery, "preferred", deliveryTypeSet); err != nil { + return err + } + if err := optionalEnumValue(delivery, "fallback", deliveryTypeSet); err != nil { + return err + } + return nil +} + +func validateAgentTaskDetailGetParams(params map[string]any) *rpcError { + if err := requireNonEmptyString(params, "task_id"); err != nil { + return err + } + return nil +} + +func validateContextEnvelope(params map[string]any) *rpcError { + context := mapObject(params, "context") + if len(context) == 0 { + return nil + } + page := mapObject(context, "page") + if err := optionalEnumValue(page, "browser_kind", browserKindSet); err != nil { + return err + } + pageContext := mapObject(mapObject(params, "input"), "page_context") + if err := optionalEnumValue(pageContext, "browser_kind", browserKindSet); 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 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 || value == "" { + return invalidParamsError("field must be a non-empty string: " + key) + } + return nil +} + +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", + } +} + func protocolParamsMap(value any) (map[string]any, *rpcError) { payload, err := json.Marshal(value) if err != nil { diff --git a/services/local-service/internal/rpc/methods.go b/services/local-service/internal/rpc/methods.go index 8fc01fd28..04f819452 100644 --- a/services/local-service/internal/rpc/methods.go +++ b/services/local-service/internal/rpc/methods.go @@ -61,7 +61,7 @@ func (s *Server) stableMethodRegistry() []registeredMethod { registered(methodAgentRecommendationGet, decodeParams, s.handleAgentRecommendationGet), registered(methodAgentRecommendationFeedbackSubmit, decodeParams, s.handleAgentRecommendationFeedbackSubmit), registered(methodAgentTaskList, decodeParams, s.handleAgentTaskList), - registered(methodAgentTaskDetailGet, decodeParams, s.handleAgentTaskDetailGet), + registered(methodAgentTaskDetailGet, decodeAgentTaskDetailGetParams, s.handleAgentTaskDetailGet), registered(methodAgentTaskEventsList, decodeParams, s.handleAgentTaskEventsList), registered(methodAgentTaskToolCallsList, decodeParams, s.handleAgentTaskToolCallsList), registered(methodAgentTaskSteer, decodeParams, s.handleAgentTaskSteer), diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index 7f20928c8..e6a887768 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -83,6 +83,65 @@ func TestAgentTaskStartDTOOmitsUnsupportedIntentField(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 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 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/server_test.go b/services/local-service/internal/rpc/server_test.go index 4516b3f68..a28c20031 100644 --- a/services/local-service/internal/rpc/server_test.go +++ b/services/local-service/internal/rpc/server_test.go @@ -412,10 +412,14 @@ func TestHandleStreamConnStreamsLoopLifecycleNotificationsBeforeResponseForSubmi Method: "agent.input.submit", Params: mustMarshal(t, map[string]any{ "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, }, @@ -498,10 +502,14 @@ func TestHandleStreamConnDoesNotReplayStreamedRuntimeNotificationsAfterResponse( Method: "agent.input.submit", Params: mustMarshal(t, map[string]any{ "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, }, @@ -1266,17 +1274,25 @@ func TestHandleStreamConnSerializesTaskStartingRequestsOnSharedConnection(t *tes method: "agent.input.submit", firstParams: map[string]any{ "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{ "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{}, }, }, { diff --git a/services/local-service/internal/rpc/server_transport_test.go b/services/local-service/internal/rpc/server_transport_test.go index dfe0c1da4..6a6464dd4 100644 --- a/services/local-service/internal/rpc/server_transport_test.go +++ b/services/local-service/internal/rpc/server_transport_test.go @@ -98,10 +98,14 @@ func TestHandleStreamConnSkipsBufferedLiveRuntimeReplay(t *testing.T) { Method: "agent.input.submit", Params: mustMarshal(t, map[string]any{ "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, }, From a2fb7c27217727be4905f9b8ab3980fd4766683a Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 02:33:20 +0000 Subject: [PATCH 09/41] fix(local-service): enforce typed rpc request meta Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../local-service/internal/rpc/entry_dto.go | 27 +++++- .../internal/rpc/protocol_registry_test.go | 90 ++++++++++++++++++- .../local-service/internal/rpc/server_test.go | 56 ++++++++++++ .../internal/rpc/server_transport_test.go | 4 + 4 files changed, 174 insertions(+), 3 deletions(-) diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index adaf06313..c47e28b5c 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -236,6 +236,9 @@ func decodeTypedProtocolParams(raw json.RawMessage, target any, validate func(ma } func validateAgentInputSubmitParams(params map[string]any) *rpcError { + if err := requireRequestMeta(params); err != nil { + return err + } input, err := requireObject(params, "input") if err != nil { return err @@ -266,6 +269,9 @@ func validateAgentInputSubmitParams(params map[string]any) *rpcError { } func validateAgentTaskStartParams(params map[string]any) *rpcError { + if err := requireRequestMeta(params); err != nil { + return err + } input, err := requireObject(params, "input") if err != nil { return err @@ -293,6 +299,9 @@ func validateAgentTaskStartParams(params map[string]any) *rpcError { } 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 } @@ -300,6 +309,10 @@ func validateAgentTaskDetailGetParams(params map[string]any) *rpcError { } func validateContextEnvelope(params map[string]any) *rpcError { + pageContext := mapObject(mapObject(params, "input"), "page_context") + if err := optionalEnumValue(pageContext, "browser_kind", browserKindSet); err != nil { + return err + } context := mapObject(params, "context") if len(context) == 0 { return nil @@ -308,8 +321,18 @@ func validateContextEnvelope(params map[string]any) *rpcError { if err := optionalEnumValue(page, "browser_kind", browserKindSet); err != nil { return err } - pageContext := mapObject(mapObject(params, "input"), "page_context") - if err := optionalEnumValue(pageContext, "browser_kind", browserKindSet); err != nil { + return nil +} + +func requireRequestMeta(params map[string]any) *rpcError { + requestMeta, err := requireObject(params, "request_meta") + if err != nil { + return err + } + if err := requireNonEmptyString(requestMeta, "trace_id"); err != nil { + return err + } + if err := requireNonEmptyString(requestMeta, "client_time"); err != nil { return err } return nil diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index e6a887768..a13cf4f5c 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -1,6 +1,7 @@ package rpc import ( + "encoding/json" "os" "path/filepath" "regexp" @@ -92,7 +93,7 @@ func TestAgentInputSubmitDTORejectsMissingStableFields(t *testing.T) { }, })) if rpcErr == nil { - t.Fatal("expected missing source/trigger/context to return invalid params") + 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) @@ -142,6 +143,93 @@ func TestAgentTaskDetailGetDTORejectsBlankTaskID(t *testing.T) { } } +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 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 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/server_test.go b/services/local-service/internal/rpc/server_test.go index a28c20031..77f33e04e 100644 --- a/services/local-service/internal/rpc/server_test.go +++ b/services/local-service/internal/rpc/server_test.go @@ -254,6 +254,10 @@ func TestHandleStreamConnEmitsLoopLifecycleNotifications(t *testing.T) { ID: json.RawMessage(`"req-task-detail"`), Method: "agent.task.detail.get", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_detail_loop_notify", + "client_time": "2026-05-10T10:00:00Z", + }, "task_id": taskID, }), } @@ -411,6 +415,10 @@ func TestHandleStreamConnStreamsLoopLifecycleNotificationsBeforeResponseForSubmi ID: json.RawMessage(`"req-input-submit-loop-stream"`), Method: "agent.input.submit", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_input_submit_loop_stream", + "client_time": "2026-05-10T10:00:00Z", + }, "session_id": "sess_input_submit_loop_stream", "source": "floating_ball", "trigger": "hover_text_input", @@ -501,6 +509,10 @@ func TestHandleStreamConnDoesNotReplayStreamedRuntimeNotificationsAfterResponse( ID: json.RawMessage(`"req-loop-no-replay"`), Method: "agent.input.submit", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_input_submit_no_replay", + "client_time": "2026-05-10T10:00:00Z", + }, "session_id": "sess_input_submit_no_replay", "source": "floating_ball", "trigger": "hover_text_input", @@ -1273,6 +1285,10 @@ func TestHandleStreamConnSerializesTaskStartingRequestsOnSharedConnection(t *tes name: "input submit", method: "agent.input.submit", firstParams: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_serialized_submit_first", + "client_time": "2026-05-10T10:00:00Z", + }, "session_id": "sess_serialized_submit", "source": "floating_ball", "trigger": "hover_text_input", @@ -1284,6 +1300,10 @@ func TestHandleStreamConnSerializesTaskStartingRequestsOnSharedConnection(t *tes "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", @@ -1299,6 +1319,10 @@ func TestHandleStreamConnSerializesTaskStartingRequestsOnSharedConnection(t *tes name: "task start", method: "agent.task.start", firstParams: map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_serialized_start_first", + "client_time": "2026-05-10T10:00:00Z", + }, "session_id": "sess_serialized_start", "source": "floating_ball", "trigger": "text_selected_click", @@ -1308,6 +1332,10 @@ func TestHandleStreamConnSerializesTaskStartingRequestsOnSharedConnection(t *tes }, }, 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", @@ -1494,6 +1522,10 @@ func TestHandleStreamConnReplaysLateTaskNotificationsBeforeQueuedSameTaskFollowU ID: json.RawMessage(`"req-late-task-starter"`), Method: "agent.task.start", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_late_task_starter", + "client_time": "2026-05-10T10:00:00Z", + }, "session_id": "sess_late_task_response_owner", "source": "floating_ball", "trigger": "text_selected_click", @@ -1794,6 +1826,10 @@ func TestHandleStreamConnKeepsQueuedReadsResponsiveWhileLoopTaskRuns(t *testing. ID: json.RawMessage(`"req-task-detail-queued"`), Method: "agent.task.detail.get", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_detail_queued", + "client_time": "2026-05-10T10:00:00Z", + }, "task_id": taskB, }), } @@ -1945,6 +1981,10 @@ func TestHandleStreamConnSerializesConcurrentRequestsForSameTask(t *testing.T) { ID: json.RawMessage(`"req-task-detail-same-task"`), Method: "agent.task.detail.get", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_detail_same_task", + "client_time": "2026-05-10T10:00:00Z", + }, "task_id": taskID, }), } @@ -2000,6 +2040,10 @@ func TestDispatchTaskStartIgnoresUnsupportedIntentField(t *testing.T) { ID: json.RawMessage(`"req-task-start-ignore-intent"`), Method: "agent.task.start", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_ignore_intent", + "client_time": "2026-05-10T10:00:00Z", + }, "session_id": "sess_ignore_intent", "source": "floating_ball", "trigger": "text_selected_click", @@ -2038,6 +2082,10 @@ func TestDispatchTaskStartFileInstructionSkipsIntentConfirmation(t *testing.T) { ID: json.RawMessage(`"req-task-start-file-instruction"`), Method: "agent.task.start", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_start_file_instruction", + "client_time": "2026-05-10T10:00:00Z", + }, "session_id": "sess_file_instruction_rpc", "source": "floating_ball", "trigger": "file_drop", @@ -2191,6 +2239,10 @@ func TestDispatchTaskDetailGetIncludesActiveApprovalAnchor(t *testing.T) { ID: json.RawMessage(`"req-task-detail-anchor"`), Method: "agent.task.detail.get", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_detail_anchor", + "client_time": "2026-05-10T10:00:00Z", + }, "task_id": taskID, }), }) @@ -2248,6 +2300,10 @@ func TestDispatchTaskDetailGetOmitsApprovalAnchorForCompletedTask(t *testing.T) ID: json.RawMessage(`"req-task-detail-no-anchor"`), Method: "agent.task.detail.get", Params: mustMarshal(t, map[string]any{ + "request_meta": map[string]any{ + "trace_id": "trace_task_detail_no_anchor", + "client_time": "2026-05-10T10:00:00Z", + }, "task_id": taskID, }), }) diff --git a/services/local-service/internal/rpc/server_transport_test.go b/services/local-service/internal/rpc/server_transport_test.go index 6a6464dd4..aedd3e014 100644 --- a/services/local-service/internal/rpc/server_transport_test.go +++ b/services/local-service/internal/rpc/server_transport_test.go @@ -97,6 +97,10 @@ 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", From a407c8597334b3dd389c166e5adb81637d0b3a54 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 03:03:34 +0000 Subject: [PATCH 10/41] fix(local-service): enforce stable rpc request meta Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../local-service/internal/rpc/entry_dto.go | 20 ++- .../local-service/internal/rpc/methods.go | 58 ++++---- .../internal/rpc/protocol_registry_test.go | 80 ++++++++++ .../local-service/internal/rpc/server_test.go | 139 +++++++++++------- .../internal/rpc/server_transport_test.go | 7 +- 5 files changed, 221 insertions(+), 83 deletions(-) diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index c47e28b5c..6cdf15160 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -3,6 +3,7 @@ package rpc import ( "bytes" "encoding/json" + "strings" ) var ( @@ -235,6 +236,23 @@ func decodeTypedProtocolParams(raw json.RawMessage, target any, validate func(ma return protocolParamsMap(target) } +func decodeParamsRequiringRequestMeta(raw json.RawMessage) (map[string]any, *rpcError) { + return decodeParamsWithValidation(raw, requireRequestMeta) +} + +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 +} + func validateAgentInputSubmitParams(params map[string]any) *rpcError { if err := requireRequestMeta(params); err != nil { return err @@ -368,7 +386,7 @@ func requireNonEmptyString(values map[string]any, key string) *rpcError { return invalidParamsError("missing required string field: " + key) } value, ok := raw.(string) - if !ok || value == "" { + if !ok || strings.TrimSpace(value) == "" { return invalidParamsError("field must be a non-empty string: " + key) } return nil diff --git a/services/local-service/internal/rpc/methods.go b/services/local-service/internal/rpc/methods.go index 04f819452..bea4953ce 100644 --- a/services/local-service/internal/rpc/methods.go +++ b/services/local-service/internal/rpc/methods.go @@ -57,36 +57,36 @@ func (s *Server) stableMethodRegistry() []registeredMethod { return []registeredMethod{ registered(methodAgentInputSubmit, decodeAgentInputSubmitParams, s.handleAgentInputSubmit), registered(methodAgentTaskStart, decodeAgentTaskStartParams, s.handleAgentTaskStart), - registered(methodAgentTaskConfirm, decodeParams, s.handleAgentTaskConfirm), - registered(methodAgentRecommendationGet, decodeParams, s.handleAgentRecommendationGet), - registered(methodAgentRecommendationFeedbackSubmit, decodeParams, s.handleAgentRecommendationFeedbackSubmit), - registered(methodAgentTaskList, decodeParams, s.handleAgentTaskList), + registered(methodAgentTaskConfirm, decodeParamsRequiringRequestMeta, s.handleAgentTaskConfirm), + registered(methodAgentRecommendationGet, decodeParamsRequiringRequestMeta, s.handleAgentRecommendationGet), + registered(methodAgentRecommendationFeedbackSubmit, decodeParamsRequiringRequestMeta, s.handleAgentRecommendationFeedbackSubmit), + registered(methodAgentTaskList, decodeParamsRequiringRequestMeta, s.handleAgentTaskList), registered(methodAgentTaskDetailGet, decodeAgentTaskDetailGetParams, 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(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, decodeParams, s.handleAgentPluginRuntimeList), registered(methodAgentPluginList, decodeParams, s.handleAgentPluginList), registered(methodAgentPluginDetailGet, decodeParams, s.handleAgentPluginDetailGet), diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index a13cf4f5c..ee578a58e 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -206,6 +206,86 @@ func TestStableDTOsRejectIncompleteRequestMeta(t *testing.T) { } } +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 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 TestAgentTaskStartDTORejectsOutOfDomainPageContextBrowserWithoutContext(t *testing.T) { _, rpcErr := decodeAgentTaskStartParams(mustMarshal(t, map[string]any{ "request_meta": map[string]any{ diff --git a/services/local-service/internal/rpc/server_test.go b/services/local-service/internal/rpc/server_test.go index 77f33e04e..8b1605c6c 100644 --- a/services/local-service/internal/rpc/server_test.go +++ b/services/local-service/internal/rpc/server_test.go @@ -117,6 +117,13 @@ type stubExecutionCapability struct { err error } +func rpcRequestMeta(traceID string) map[string]any { + return map[string]any{ + "trace_id": traceID, + "client_time": "2026-05-10T00:00:00Z", + } +} + func (s stubExecutionCapability) RunCommand(_ context.Context, _ string, _ []string, _ string) (tools.CommandExecutionResult, error) { if s.err != nil { return tools.CommandExecutionResult{}, s.err @@ -169,8 +176,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{ @@ -331,8 +339,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{}, @@ -626,8 +635,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{}, @@ -1652,7 +1662,9 @@ 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"), + }), }); err != nil { t.Fatalf("encode task.list request: %v", err) } @@ -1803,8 +1815,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{}, @@ -1958,8 +1971,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{}, @@ -2348,8 +2362,9 @@ func TestDispatchMapsTaskControlStatusErrors(t *testing.T) { ID: json.RawMessage(`"req-task-control-invalid"`), Method: "agent.task.control", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - "action": "pause", + "request_meta": rpcRequestMeta("trace_task_control_invalid_status"), + "task_id": taskID, + "action": "pause", }), }) @@ -2393,8 +2408,9 @@ func TestDispatchMapsTaskControlFinishedErrors(t *testing.T) { ID: json.RawMessage(`"req-task-control-finished"`), Method: "agent.task.control", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - "action": "cancel", + "request_meta": rpcRequestMeta("trace_task_control_finished"), + "task_id": taskID, + "action": "cancel", }), }) @@ -2431,9 +2447,10 @@ func TestDispatchReturnsSecurityAuditList(t *testing.T) { ID: json.RawMessage(`"req-security-audit-list"`), Method: "agent.security.audit.list", Params: mustMarshal(t, map[string]any{ - "task_id": "task_001", - "limit": 20, - "offset": 0, + "request_meta": rpcRequestMeta("trace_security_audit_list"), + "task_id": "task_001", + "limit": 20, + "offset": 0, }), }) @@ -2471,9 +2488,10 @@ func TestDispatchReturnsSecurityRestorePointsList(t *testing.T) { ID: json.RawMessage(`"req-security-restore-points-list"`), Method: "agent.security.restore_points.list", Params: mustMarshal(t, map[string]any{ - "task_id": "task_001", - "limit": 20, - "offset": 0, + "request_meta": rpcRequestMeta("trace_security_restore_points"), + "task_id": "task_001", + "limit": 20, + "offset": 0, }), }) @@ -2525,6 +2543,7 @@ func TestDispatchReturnsSecurityRestoreApplyResult(t *testing.T) { ID: json.RawMessage(`"req-security-restore-apply"`), Method: "agent.security.restore.apply", Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_security_restore_apply"), "task_id": taskID, "recovery_point_id": "rp_001", }), @@ -2554,8 +2573,9 @@ func TestDispatchReturnsNotepadUpdateResult(t *testing.T) { ID: json.RawMessage(`"req-notepad-update"`), Method: "agent.notepad.update", Params: mustMarshal(t, map[string]any{ - "item_id": "todo_002", - "action": "move_upcoming", + "request_meta": rpcRequestMeta("trace_notepad_update"), + "item_id": "todo_002", + "action": "move_upcoming", }), }) @@ -2602,9 +2622,10 @@ func TestDispatchReturnsTaskArtifactList(t *testing.T) { ID: json.RawMessage(`"req-task-artifact-list"`), Method: "agent.task.artifact.list", Params: mustMarshal(t, map[string]any{ - "task_id": "task_rpc_001", - "limit": 20, - "offset": 0, + "request_meta": rpcRequestMeta("trace_task_artifact_list"), + "task_id": "task_rpc_001", + "limit": 20, + "offset": 0, }), }) success, ok := response.(successEnvelope) @@ -2641,8 +2662,9 @@ func TestDispatchReturnsTaskArtifactOpen(t *testing.T) { ID: json.RawMessage(`"req-task-artifact-open"`), Method: "agent.task.artifact.open", Params: mustMarshal(t, map[string]any{ - "task_id": "task_rpc_open_001", - "artifact_id": "art_rpc_open_001", + "request_meta": rpcRequestMeta("trace_task_artifact_open"), + "task_id": "task_rpc_open_001", + "artifact_id": "art_rpc_open_001", }), }) success, ok := response.(successEnvelope) @@ -2692,8 +2714,9 @@ func TestDispatchReturnsDeliveryOpenForArtifact(t *testing.T) { ID: json.RawMessage(`"req-delivery-open-artifact"`), Method: "agent.delivery.open", Params: mustMarshal(t, map[string]any{ - "task_id": "task_delivery_rpc_001", - "artifact_id": "art_delivery_rpc_001", + "request_meta": rpcRequestMeta("trace_delivery_open_artifact"), + "task_id": "task_delivery_rpc_001", + "artifact_id": "art_delivery_rpc_001", }), }) success, ok := response.(successEnvelope) @@ -2732,7 +2755,8 @@ func TestDispatchReturnsDeliveryOpenForTaskResult(t *testing.T) { ID: json.RawMessage(`"req-delivery-open-task"`), Method: "agent.delivery.open", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, + "request_meta": rpcRequestMeta("trace_delivery_open_task"), + "task_id": taskID, }), }) success, ok := response.(successEnvelope) @@ -2881,6 +2905,7 @@ func TestDispatchReturnsFormalTaskInspectorRunSourceErrors(t *testing.T) { ID: json.RawMessage(fmt.Sprintf(`"%s"`, test.requestID)), Method: "agent.task_inspector.run", Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_inspector_run"), "target_sources": test.targetSources, }), }) @@ -2978,7 +3003,10 @@ func TestDispatchReturnsSettingsGet(t *testing.T) { JSONRPC: "2.0", ID: json.RawMessage(`"req-settings-get"`), Method: "agent.settings.get", - 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 { @@ -3001,6 +3029,7 @@ func TestDispatchReturnsSettingsUpdate(t *testing.T) { ID: json.RawMessage(`"req-settings-update"`), Method: "agent.settings.update", Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_settings_update"), "models": map[string]any{ "provider": "openai", "api_key": "rpc-secret-key", @@ -3030,6 +3059,7 @@ func TestDispatchReturnsSettingsModelValidate(t *testing.T) { ID: json.RawMessage(`"req-settings-model-validate"`), Method: "agent.settings.model.validate", 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", @@ -3157,8 +3187,9 @@ func TestDispatchMapsTaskControlInvalidActionToInvalidParams(t *testing.T) { ID: json.RawMessage(`"req-task-control-invalid-action"`), Method: "agent.task.control", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - "action": "skip", + "request_meta": rpcRequestMeta("trace_task_control_invalid_action"), + "task_id": taskID, + "action": "skip", }), }) @@ -3179,7 +3210,8 @@ func TestDispatchMapsTaskControlMissingTaskIDToInvalidParams(t *testing.T) { ID: json.RawMessage(`"req-task-control-missing-task-id"`), Method: "agent.task.control", Params: mustMarshal(t, map[string]any{ - "action": "pause", + "request_meta": rpcRequestMeta("trace_task_control_missing_task_id"), + "action": "pause", }), }) @@ -3220,7 +3252,8 @@ func TestDispatchMapsTaskControlMissingActionToInvalidParams(t *testing.T) { ID: json.RawMessage(`"req-task-control-missing-action"`), Method: "agent.task.control", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, + "request_meta": rpcRequestMeta("trace_task_control_missing_action"), + "task_id": taskID, }), }) @@ -3262,11 +3295,12 @@ func TestDispatchTaskListClampsPagingParams(t *testing.T) { ID: json.RawMessage(`"req-task-list-clamp"`), Method: "agent.task.list", 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", }), }) @@ -3317,11 +3351,12 @@ func TestDispatchTaskEventsListReturnsLoopEvents(t *testing.T) { ID: json.RawMessage(`"req-task-events-list"`), Method: "agent.task.events.list", 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, }), }) @@ -3359,9 +3394,10 @@ func TestDispatchTaskToolCallsListReturnsPersistedToolCalls(t *testing.T) { ID: json.RawMessage(`"req-task-tool-calls-list"`), Method: "agent.task.tool_calls.list", Params: mustMarshal(t, map[string]any{ - "task_id": "task_rpc_tool_001", - "limit": 20, - "offset": 0, + "request_meta": rpcRequestMeta("trace_task_tool_calls_list"), + "task_id": "task_rpc_tool_001", + "limit": 20, + "offset": 0, }), }) @@ -3403,8 +3439,9 @@ func TestDispatchTaskSteerReturnsUpdatedTask(t *testing.T) { ID: json.RawMessage(`"req-task-steer"`), Method: "agent.task.steer", Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - "message": "Also include a short summary section.", + "request_meta": rpcRequestMeta("trace_task_steer"), + "task_id": taskID, + "message": "Also include a short summary section.", }), }) diff --git a/services/local-service/internal/rpc/server_transport_test.go b/services/local-service/internal/rpc/server_transport_test.go index aedd3e014..b5b2e8dfc 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) @@ -248,7 +251,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) From ac4eb307a3e194b0b02470f935f637841127ac91 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 03:31:08 +0000 Subject: [PATCH 11/41] fix(local-service): normalize typed dto boundaries Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../orchestrator/protocol_dto_test.go | 160 +++++++++++++++ .../orchestrator/protocol_request_dto.go | 6 - .../orchestrator/protocol_response_dto.go | 184 +++++++++++++++--- .../local-service/internal/rpc/entry_dto.go | 150 ++------------ 4 files changed, 324 insertions(+), 176 deletions(-) create mode 100644 services/local-service/internal/orchestrator/protocol_dto_test.go 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..6cdb1f754 --- /dev/null +++ b/services/local-service/internal/orchestrator/protocol_dto_test.go @@ -0,0 +1,160 @@ +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.paramsMap() + 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 := 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", + }) + + 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 TestTaskDetailGetResponseMapNormalizesUnknownFields(t *testing.T) { + response := newTaskDetailGetResponse(map[string]any{ + "task": map[string]any{ + "task_id": "task_detail_123", + "session_id": "sess_123", + "title": "Explain error", + "source_type": "error", + "status": "completed", + "intent": nil, + "current_step": "generate_output", + "risk_level": "green", + "started_at": "2026-05-10T00:00:00Z", + "updated_at": "2026-05-10T00:00:01Z", + "finished_at": "2026-05-10T00:00:02Z", + "unknown": "drop-me", + }, + "timeline": []map[string]any{}, + "delivery_result": map[string]any{ + "type": "bubble", + "title": "Explanation", + "preview_text": "Summary", + "payload": map[string]any{ + "path": nil, + "url": nil, + "task_id": "task_detail_123", + "unknown": "drop-me", + }, + "unknown": "drop-me", + }, + "artifacts": []map[string]any{}, + "citations": []map[string]any{}, + "mirror_references": []map[string]any{}, + "approval_request": nil, + "authorization_record": nil, + "audit_record": nil, + "security_summary": map[string]any{"pending_authorizations": 0, "latest_restore_point": nil, "unknown": "drop-me"}, + "runtime_summary": map[string]any{"loop_stop_reason": nil, "events_count": 0, "latest_event_type": nil, "active_steering_count": 0, "latest_failure_code": nil, "latest_failure_category": nil, "latest_failure_summary": nil, "observation_signals": []string{}, "unknown": "drop-me"}, + "unknown": "drop-me", + }) + + mapped := response.Map() + if _, ok := mapped["unknown"]; ok { + t.Fatalf("expected typed detail normalization to drop unknown top-level fields, got %+v", mapped) + } + deliveryResult := mapValue(mapped, "delivery_result") + if _, ok := deliveryResult["unknown"]; ok { + t.Fatalf("expected typed detail normalization to drop unknown delivery_result fields, got %+v", deliveryResult) + } + payload := mapValue(deliveryResult, "payload") + if _, ok := payload["unknown"]; ok { + t.Fatalf("expected typed detail normalization to drop unknown delivery payload fields, got %+v", payload) + } + securitySummary := mapValue(mapped, "security_summary") + if _, ok := securitySummary["unknown"]; ok { + t.Fatalf("expected typed detail normalization to drop unknown security summary fields, got %+v", securitySummary) + } + runtimeSummary := mapValue(mapped, "runtime_summary") + if _, ok := runtimeSummary["unknown"]; ok { + t.Fatalf("expected typed detail normalization to drop unknown runtime summary fields, got %+v", runtimeSummary) + } +} diff --git a/services/local-service/internal/orchestrator/protocol_request_dto.go b/services/local-service/internal/orchestrator/protocol_request_dto.go index 4352e9104..9642f561e 100644 --- a/services/local-service/internal/orchestrator/protocol_request_dto.go +++ b/services/local-service/internal/orchestrator/protocol_request_dto.go @@ -110,8 +110,6 @@ type SubmitInputRequest struct { Context *InputContext `json:"context,omitempty"` VoiceMeta *VoiceMeta `json:"voice_meta,omitempty"` Options *InputSubmitOptions `json:"options,omitempty"` - - raw map[string]any } // TaskStartInput is the formal input object for agent.task.start. @@ -147,8 +145,6 @@ type StartTaskRequest struct { Delivery *DeliveryPreference `json:"delivery,omitempty"` Options *TaskStartOptions `json:"options,omitempty"` Intent map[string]any `json:"-"` - - raw map[string]any } // TaskDetailGetRequest is the typed orchestrator boundary for @@ -156,6 +152,4 @@ type StartTaskRequest struct { type TaskDetailGetRequest struct { RequestMeta RequestMeta `json:"request_meta"` TaskID string `json:"task_id"` - - raw map[string]any } diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index b41562489..2f6b8f5d0 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -1,12 +1,16 @@ package orchestrator -import "encoding/json" +import ( + "encoding/json" + "reflect" + "strings" +) // IntentPayload keeps intent arguments dynamic while making the outer protocol // object explicit. type IntentPayload struct { - Name string `json:"name,omitempty"` - Arguments map[string]any `json:"arguments,omitempty"` + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` } // TaskDTO is the protocol-facing task object returned through stable RPCs. @@ -164,8 +168,6 @@ type TaskEntryResponse struct { Task *TaskDTO `json:"task"` BubbleMessage *BubbleMessageDTO `json:"bubble_message"` DeliveryResult *DeliveryResultDTO `json:"delivery_result"` - - raw map[string]any } // TaskDetailGetResponse is the typed result for agent.task.detail.get. @@ -181,44 +183,39 @@ type TaskDetailGetResponse struct { AuditRecord *AuditRecordDTO `json:"audit_record"` SecuritySummary SecuritySummaryDTO `json:"security_summary"` RuntimeSummary TaskRuntimeSummaryDTO `json:"runtime_summary"` - - raw map[string]any } // StartTaskRequestFromParams adapts RPC-decoded params to the typed -// orchestrator request. The map is accepted only at the RPC adapter boundary. +// orchestrator request. The map is accepted only at the RPC adapter boundary, +// then normalized back through the typed DTO before orchestration continues. func StartTaskRequestFromParams(params map[string]any) StartTaskRequest { var request StartTaskRequest decodeProtocolMap(params, &request) if intent := mapValue(params, "intent"); len(intent) > 0 { request.Intent = cloneMap(intent) } - request.raw = cloneMap(params) return request } // SubmitInputRequestFromParams adapts RPC-decoded params to the typed -// orchestrator request. The map is accepted only at the RPC adapter boundary. +// orchestrator request. The map is accepted only at the RPC adapter boundary, +// then normalized back through the typed DTO before orchestration continues. func SubmitInputRequestFromParams(params map[string]any) SubmitInputRequest { var request SubmitInputRequest decodeProtocolMap(params, &request) - request.raw = cloneMap(params) return request } // TaskDetailGetRequestFromParams adapts RPC-decoded params to the typed -// orchestrator request. The map is accepted only at the RPC adapter boundary. +// orchestrator request. The map is accepted only at the RPC adapter boundary, +// then normalized back through the typed DTO before orchestration continues. func TaskDetailGetRequestFromParams(params map[string]any) TaskDetailGetRequest { var request TaskDetailGetRequest decodeProtocolMap(params, &request) - request.raw = cloneMap(params) return request } func (r StartTaskRequest) paramsMap() map[string]any { - if r.raw != nil { - return cloneMap(r.raw) - } params := structToProtocolMap(r) if len(r.Intent) > 0 { params["intent"] = cloneMap(r.Intent) @@ -227,49 +224,35 @@ func (r StartTaskRequest) paramsMap() map[string]any { } func (r SubmitInputRequest) paramsMap() map[string]any { - if r.raw != nil { - return cloneMap(r.raw) - } return structToProtocolMap(r) } func (r TaskDetailGetRequest) paramsMap() map[string]any { - if r.raw != nil { - return cloneMap(r.raw) - } return structToProtocolMap(r) } func newTaskEntryResponse(payload map[string]any) TaskEntryResponse { var response TaskEntryResponse decodeProtocolMap(payload, &response) - response.raw = cloneMap(payload) return response } func newTaskDetailGetResponse(payload map[string]any) TaskDetailGetResponse { var response TaskDetailGetResponse decodeProtocolMap(payload, &response) - response.raw = cloneMap(payload) return response } // 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 { - if r.raw != nil { - return cloneMap(r.raw) - } - return structToProtocolMap(r) + return responseDTOToProtocolMap(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 { - if r.raw != nil { - return cloneMap(r.raw) - } - return structToProtocolMap(r) + return responseDTOToProtocolMap(r) } func decodeProtocolMap(values map[string]any, target any) { @@ -294,3 +277,140 @@ func structToProtocolMap(value any) map[string]any { } return result } + +func responseDTOToProtocolMap(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/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index 6cdf15160..7db433166 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "strings" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" ) var ( @@ -50,146 +52,18 @@ var ( } ) -// 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"` -} - -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"` -} - -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"` -} - -type selectionContext struct { - Text string `json:"text,omitempty"` -} - -type errorContext struct { - Message string `json:"message,omitempty"` -} - -type clipboardContext struct { - Text string `json:"text,omitempty"` -} - -// 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"` -} - -type agentInputSubmitInput struct { - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` - InputMode string `json:"input_mode,omitempty"` -} - -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"` -} - -type agentInputSubmitOptions struct { - ConfirmRequired bool `json:"confirm_required,omitempty"` - PreferredDelivery string `json:"preferred_delivery,omitempty"` -} - -// 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"` -} - -type agentTaskStartOptions struct { - ConfirmRequired bool `json:"confirm_required,omitempty"` -} +// AgentInputSubmitParams reuses the orchestrator request DTO so the stable +// contract only needs to be maintained in one Go package. +type AgentInputSubmitParams = orchestrator.SubmitInputRequest -// 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"` -} +// 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 mirrors packages/protocol AgentTaskDetailGetParams -// so stable detail requests can reject incomplete task identifiers at the RPC -// boundary instead of reaching orchestrator lookups as empty strings. -type AgentTaskDetailGetParams struct { - RequestMeta RequestMeta `json:"request_meta"` - TaskID string `json:"task_id"` -} +// AgentTaskDetailGetParams reuses the orchestrator request DTO so stable task +// detail lookups validate the same typed contract the orchestrator consumes. +type AgentTaskDetailGetParams = orchestrator.TaskDetailGetRequest func decodeAgentInputSubmitParams(raw json.RawMessage) (map[string]any, *rpcError) { var params AgentInputSubmitParams From a098f6a1ed213a668f28ce64ff64492b9cbdd9db Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 05:02:08 +0000 Subject: [PATCH 12/41] test(local-service): align rpc tests with latest main Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/rpc/debug_http_test.go | 93 + .../internal/rpc/dispatch_core_test.go | 610 +++ .../internal/rpc/dispatch_storage_test.go | 344 ++ .../internal/rpc/error_specs_test.go | 32 + .../internal/rpc/handlers_test.go | 41 + .../internal/rpc/jsonrpc_test.go | 156 + .../rpc/protocol_dto_test_helpers_test.go | 27 - .../internal/rpc/protocol_registry_test.go | 33 + .../internal/rpc/rpc_test_helpers_test.go | 247 ++ .../rpc/server_stream_coordination_test.go | 761 ++++ .../rpc/server_stream_notifications_test.go | 663 +++ .../internal/rpc/server_stream_queue_test.go | 494 +++ .../local-service/internal/rpc/server_test.go | 3557 ----------------- 13 files changed, 3474 insertions(+), 3584 deletions(-) create mode 100644 services/local-service/internal/rpc/debug_http_test.go create mode 100644 services/local-service/internal/rpc/dispatch_core_test.go create mode 100644 services/local-service/internal/rpc/dispatch_storage_test.go create mode 100644 services/local-service/internal/rpc/handlers_test.go create mode 100644 services/local-service/internal/rpc/jsonrpc_test.go delete mode 100644 services/local-service/internal/rpc/protocol_dto_test_helpers_test.go create mode 100644 services/local-service/internal/rpc/rpc_test_helpers_test.go create mode 100644 services/local-service/internal/rpc/server_stream_coordination_test.go create mode 100644 services/local-service/internal/rpc/server_stream_notifications_test.go create mode 100644 services/local-service/internal/rpc/server_stream_queue_test.go delete mode 100644 services/local-service/internal/rpc/server_test.go diff --git a/services/local-service/internal/rpc/debug_http_test.go b/services/local-service/internal/rpc/debug_http_test.go new file mode 100644 index 000000000..506e1409f --- /dev/null +++ b/services/local-service/internal/rpc/debug_http_test.go @@ -0,0 +1,93 @@ +package rpc + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +// TestHandleDebugEventsReturnsQueuedNotifications verifies that queued +// notifications can be fetched through the debug events endpoint. +func TestHandleDebugEventsReturnsQueuedNotifications(t *testing.T) { + server := newTestServer() + result, err := startTaskForTest(server.orchestrator, 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", + }, + }, + }) + if err != nil { + t.Fatalf("start task: %v", err) + } + + taskID := result["task"].(map[string]any)["task_id"].(string) + recorder := httptest.NewRecorder() + request := httptest.NewRequest("GET", "/events?task_id="+taskID, nil) + server.handleDebugEvents(recorder, request) + + if recorder.Code != 200 { + t.Fatalf("expected 200 status, got %d", recorder.Code) + } + + var payload map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + + items := payload["items"].([]any) + if len(items) == 0 { + t.Fatal("expected queued notifications to be returned") + } +} + +func TestHandleHTTPRPCAllowsLoopbackStyleOrigins(t *testing.T) { + server := newTestServer() + origins := []string{ + "http://localhost:5173", + "https://127.0.0.1:5173", + "tauri://localhost", + "https://tauri.localhost", + } + + for _, origin := range origins { + t.Run(origin, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest("OPTIONS", "/rpc", nil) + request.Header.Set("Origin", origin) + + server.handleHTTPRPC(recorder, request) + + if recorder.Code != 204 { + t.Fatalf("expected 204 status, got %d", recorder.Code) + } + if recorder.Header().Get("Access-Control-Allow-Origin") != origin { + t.Fatalf("expected CORS allow origin %q, got %q", origin, recorder.Header().Get("Access-Control-Allow-Origin")) + } + }) + } +} + +func TestHandleHTTPRPCRejectsNonLoopbackOrigins(t *testing.T) { + server := newTestServer() + recorder := httptest.NewRecorder() + request := httptest.NewRequest("OPTIONS", "/rpc", nil) + request.Header.Set("Origin", "https://example.com") + + server.handleHTTPRPC(recorder, request) + + if recorder.Code != 204 { + t.Fatalf("expected 204 status, got %d", recorder.Code) + } + if recorder.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("expected no CORS allow origin for non-loopback request, got %q", recorder.Header().Get("Access-Control-Allow-Origin")) + } +} diff --git a/services/local-service/internal/rpc/dispatch_core_test.go b/services/local-service/internal/rpc/dispatch_core_test.go new file mode 100644 index 000000000..25b9b8a42 --- /dev/null +++ b/services/local-service/internal/rpc/dispatch_core_test.go @@ -0,0 +1,610 @@ +package rpc + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "testing" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/plugin" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/storage" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/tools" +) + +func TestDispatchTaskStartIgnoresUnsupportedIntentField(t *testing.T) { + server := newTestServer() + + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-start-ignore-intent"`), + Method: methodAgentTaskStart, + Params: mustMarshal(t, map[string]any{ + "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", + }, + "intent": map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, + }, + }, + }), + }) + + success, ok := response.(successEnvelope) + if !ok { + t.Fatalf("expected success response envelope, got %#v", response) + } + task := success.Result.Data.(map[string]any)["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) + } + intentValue, ok := task["intent"].(map[string]any) + if !ok || intentValue["name"] != "agent_loop" { + t.Fatalf("expected task.start to rely on backend suggestion instead of request intent, got %+v", task["intent"]) + } +} + +func TestDispatchTaskStartFileInstructionSkipsIntentConfirmation(t *testing.T) { + server := newTestServerWithModelClient(&stubLoopModelClient{}) + + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-start-file-instruction"`), + Method: methodAgentTaskStart, + Params: mustMarshal(t, map[string]any{ + "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": "帮我看看这里面有什么", + "files": []string{"workspace/MyToDos_Vue"}, + }, + "options": map[string]any{ + "confirm_required": false, + }, + "delivery": map[string]any{ + "preferred": "bubble", + "fallback": "task_detail", + }, + }), + }) + + success, ok := response.(successEnvelope) + if !ok { + t.Fatalf("expected success response envelope, got %#v", response) + } + result := success.Result.Data.(map[string]any) + 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) + } + if task["source_type"] != "dragged_file" { + t.Fatalf("expected dragged_file source type, got %+v", task) + } + intentValue, ok := task["intent"].(map[string]any) + if !ok || intentValue["name"] != "agent_loop" { + t.Fatalf("expected task.start to keep backend agent_loop suggestion, got %+v", task["intent"]) + } + if result["delivery_result"] == nil { + t.Fatal("expected instructed file start to return delivery_result") + } +} + +func TestDispatchTaskDetailGetIncludesActiveApprovalAnchor(t *testing.T) { + server := newTestServer() + + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_detail_rpc", + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "rpc task detail should expose active approval anchor", + }, + "intent": map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, + }, + }, + }) + if err != nil { + t.Fatalf("start task: %v", err) + } + + taskID := startResult["task"].(map[string]any)["task_id"].(string) + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-detail-anchor"`), + Method: methodAgentTaskDetailGet, + 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) + 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"]) + } + securitySummary := data["security_summary"].(map[string]any) + if numericValue(t, securitySummary["pending_authorizations"]) != 1 { + t.Fatalf("expected pending_authorizations 1, got %+v", securitySummary["pending_authorizations"]) + } + if securitySummary["latest_restore_point"] != nil { + t.Fatalf("expected latest_restore_point nil, got %+v", securitySummary["latest_restore_point"]) + } +} + +func TestDispatchTaskDetailGetOmitsApprovalAnchorForCompletedTask(t *testing.T) { + server := newTestServer() + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_detail_rpc_done", + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "rpc task detail should omit anchor for completed task", + }, + "intent": map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", + }, + }, + }) + if err != nil { + t.Fatalf("start task: %v", err) + } + + taskID := startResult["task"].(map[string]any)["task_id"].(string) + if _, ok := server.orchestrator.RunEngine().CompleteTask(taskID, map[string]any{"type": "task_detail", "payload": map[string]any{"task_id": taskID}}, map[string]any{"task_id": taskID, "type": "result", "text": "done"}, nil); !ok { + t.Fatal("expected runtime task completion to succeed") + } + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-detail-no-anchor"`), + Method: methodAgentTaskDetailGet, + 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) + if data["approval_request"] != nil { + t.Fatalf("expected approval_request to be nil, got %+v", data["approval_request"]) + } + securitySummary := data["security_summary"].(map[string]any) + if numericValue(t, securitySummary["pending_authorizations"]) != 0 { + t.Fatalf("expected pending_authorizations 0, got %+v", securitySummary["pending_authorizations"]) + } + if _, ok := securitySummary["latest_restore_point"].(map[string]any); !ok { + t.Fatalf("expected latest_restore_point object, got %+v", securitySummary["latest_restore_point"]) + } +} + +func TestDispatchTaskControlValidationAndStatusErrors(t *testing.T) { + server := newTestServer() + + completedTask, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_demo", + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "task control rpc validation", + }, + "intent": map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, + }, + }, + }) + if err != nil { + t.Fatalf("start task: %v", err) + } + completedTaskID := completedTask["task"].(map[string]any)["task_id"].(string) + if _, ok := server.orchestrator.RunEngine().CompleteTask(completedTaskID, map[string]any{"type": "task_detail", "payload": map[string]any{"task_id": completedTaskID}}, map[string]any{"task_id": completedTaskID, "type": "result", "text": "done"}, nil); !ok { + t.Fatal("expected runtime task completion to succeed") + } + + waitingTask, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_waiting_task", + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "still waiting for intent confirmation", + }, + }) + if err != nil { + t.Fatalf("start waiting task: %v", err) + } + waitingTaskID := waitingTask["task"].(map[string]any)["task_id"].(string) + + tests := []struct { + name string + params map[string]any + expectedCode int + expectedError string + }{ + {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 { + t.Run(test.name, func(t *testing.T) { + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-control"`), + Method: methodAgentTaskControl, + Params: mustMarshal(t, test.params), + }) + errEnvelope, ok := response.(errorEnvelope) + if !ok { + t.Fatalf("expected error response envelope, got %#v", response) + } + if errEnvelope.Error.Code != test.expectedCode || errEnvelope.Error.Message != test.expectedError { + t.Fatalf("expected %s mapping, got code=%d message=%s", test.expectedError, errEnvelope.Error.Code, errEnvelope.Error.Message) + } + }) + } +} + +func TestDispatchTaskListClampsPagingParams(t *testing.T) { + server := newTestServer() + for index := 0; index < 25; index++ { + _, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": fmt.Sprintf("sess_rpc_task_list_%02d", index), + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": fmt.Sprintf("rpc task list clamp %02d", index), + }, + "intent": map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, + }, + }, + }) + if err != nil { + t.Fatalf("start task %d: %v", index, err) + } + } + + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-list-clamp"`), + Method: methodAgentTaskList, + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_list_clamp"), + "group": "unfinished", + "limit": 0, + "offset": -5, + "sort_by": "updated_at", + "sort_order": "desc", + }), + }) + + 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) + if len(items) != 20 { + t.Fatalf("expected rpc task.list to clamp zero limit to 20 items, got %d", len(items)) + } + page := data["page"].(map[string]any) + if numericValue(t, page["limit"]) != 20 || numericValue(t, page["offset"]) != 0 || page["has_more"] != true || numericValue(t, page["total"]) != 25 { + t.Fatalf("unexpected clamped page %+v", page) + } +} + +func TestDispatchTaskEventsListReturnsLoopEvents(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(testStorageAdapter{databasePath: filepath.Join(t.TempDir(), "rpc-loop-events.db")}) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + if err := storageService.LoopRuntimeStore().SaveEvents(context.Background(), []storage.EventRecord{{ + EventID: "evt_rpc_loop_001", + RunID: "run_rpc_loop_001", + TaskID: "task_rpc_loop_001", + StepID: "step_rpc_loop_001", + Type: "loop.completed", + Level: "info", + PayloadJSON: `{"stop_reason":"completed"}`, + CreatedAt: "2026-04-17T10:00:00Z", + }}); err != nil { + t.Fatalf("save loop events failed: %v", err) + } + + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-events-list"`), + Method: methodAgentTaskEventsList, + Params: mustMarshal(t, map[string]any{ + "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, + }), + }) + + 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) + if len(items) != 1 || items[0]["type"] != "loop.completed" { + t.Fatalf("expected rpc task events list to return loop.completed, got %+v", items) + } +} + +func TestDispatchTaskToolCallsListReturnsPersistedToolCalls(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(testStorageAdapter{databasePath: filepath.Join(t.TempDir(), "rpc-tool-calls.db")}) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + if err := storageService.ToolCallStore().SaveToolCall(context.Background(), tools.ToolCallRecord{ + ToolCallID: "tool_call_rpc_001", + RunID: "run_rpc_tool_001", + TaskID: "task_rpc_tool_001", + ToolName: "read_file", + Status: tools.ToolCallStatusSucceeded, + Input: map[string]any{"path": "notes/source.txt"}, + Output: map[string]any{"path": "notes/source.txt"}, + DurationMS: 9, + }); err != nil { + t.Fatalf("save tool call failed: %v", err) + } + + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-tool-calls-list"`), + Method: methodAgentTaskToolCallsList, + 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) + 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) + } +} + +func TestDispatchTaskSteerReturnsUpdatedTask(t *testing.T) { + server := newTestServer() + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_rpc_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, + }, + }, + }) + if err != nil { + t.Fatalf("start task: %v", err) + } + taskID := startResult["task"].(map[string]any)["task_id"].(string) + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-steer"`), + Method: methodAgentTaskSteer, + 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) + } +} + +func TestDispatchReturnsSettingsGet(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(testStorageAdapter{databasePath: filepath.Join(t.TempDir(), "settings-get.db")}) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-settings-get"`), + Method: methodAgentSettingsGet, + 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) + if _, ok := credentials["stronghold"].(map[string]any); !ok { + t.Fatalf("expected settings get to include stronghold status, got %+v", credentials) + } +} + +func TestDispatchReturnsSettingsUpdate(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(testStorageAdapter{databasePath: filepath.Join(t.TempDir(), "settings-update.db")}) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + 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", + }, + }), + }) + success, ok := response.(successEnvelope) + 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) + 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) + } +} + +func TestDispatchReturnsSettingsModelValidate(t *testing.T) { + server := newTestServer() + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + 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", + "model": "gpt-4.1-mini", + }, + }), + }) + success, ok := response.(successEnvelope) + if !ok { + t.Fatalf("expected success response envelope, got %#v", response) + } + data := success.Result.Data.(map[string]any) + if data["ok"] != false || data["status"] != "missing_api_key" { + t.Fatalf("expected structured validation failure result, got %+v", data) + } +} + +func TestDispatchReturnsPluginRuntimeList(t *testing.T) { + server := newTestServer() + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-plugin-runtime-list"`), + Method: methodAgentPluginRuntimeList, + Params: mustMarshal(t, map[string]any{}), + }) + 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) + if len(items) == 0 { + t.Fatalf("expected plugin runtime query to return declared runtimes, got %+v", data) + } + manifest, ok := items[0]["manifest"].(map[string]any) + 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) + if len(metrics) == 0 { + t.Fatalf("expected plugin runtime query to return metric snapshots, got %+v", data) + } +} + +func TestDispatchReturnsPluginList(t *testing.T) { + server := newTestServer() + response := server.dispatch(requestEnvelope{ + 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}}), + }) + 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) + 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) + } +} + +func TestDispatchReturnsPluginDetail(t *testing.T) { + server, toolRegistry, pluginService := newTestServerWithDependencies(nil) + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-plugin-detail"`), + Method: methodAgentPluginDetailGet, + Params: mustMarshal(t, map[string]any{"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) + if data["plugin"].(map[string]any)["plugin_id"] != "ocr" { + t.Fatalf("expected plugin detail query to resolve ocr plugin, got %+v", data) + } + runtime, ok := pluginService.RuntimeState(plugin.RuntimeKindWorker, "ocr_worker") + if !ok { + t.Fatalf("expected ocr worker runtime to exist") + } + toolItems := data["tools"].([]map[string]any) + if len(toolItems) != len(runtime.Capabilities) { + t.Fatalf("expected one contract per declared capability, got %+v", data) + } + for _, item := range toolItems { + toolName := item["tool_name"].(string) + tool, err := toolRegistry.Get(toolName) + if err != nil { + t.Fatalf("expected tool %q to exist in registry: %v", toolName, err) + } + metadata := tool.Metadata() + if item["display_name"] != metadata.DisplayName || item["source"] != string(metadata.Source) { + t.Fatalf("expected plugin detail payload to mirror registry metadata for %q, got %+v", toolName, item) + } + } +} diff --git a/services/local-service/internal/rpc/dispatch_storage_test.go b/services/local-service/internal/rpc/dispatch_storage_test.go new file mode 100644 index 000000000..cbb5466cd --- /dev/null +++ b/services/local-service/internal/rpc/dispatch_storage_test.go @@ -0,0 +1,344 @@ +package rpc + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "testing" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/audit" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/checkpoint" + serviceconfig "github.com/cialloclaw/cialloclaw/services/local-service/internal/config" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/platform" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/storage" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/taskinspector" +) + +func TestDispatchReturnsSecurityAuditList(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "audit.db"))) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + err := storageService.AuditWriter().WriteAuditRecord(context.Background(), audit.Record{ + AuditID: "audit_001", + TaskID: "task_001", + Type: "file", + Action: "write_file", + Summary: "stored audit record", + Target: "workspace/result.md", + Result: "success", + CreatedAt: "2026-04-08T10:00:00Z", + }) + if err != nil { + t.Fatalf("write audit record: %v", err) + } + + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-security-audit-list"`), + Method: methodAgentSecurityAuditList, + 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) + if len(items) != 1 || items[0]["audit_id"] != "audit_001" { + t.Fatalf("expected stored audit_001, got %+v", items) + } +} + +func TestDispatchReturnsSecurityRestorePointsList(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "restore.db"))) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + err := storageService.RecoveryPointWriter().WriteRecoveryPoint(context.Background(), checkpoint.RecoveryPoint{ + RecoveryPointID: "rp_001", + TaskID: "task_001", + Summary: "stored recovery point", + CreatedAt: "2026-04-08T10:00:00Z", + Objects: []string{"workspace/result.md"}, + }) + if err != nil { + t.Fatalf("write recovery point: %v", err) + } + + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-security-restore-points-list"`), + Method: methodAgentSecurityRestorePointsList, + 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) + if len(items) != 1 || items[0]["recovery_point_id"] != "rp_001" { + t.Fatalf("expected stored rp_001, got %+v", items) + } +} + +func TestDispatchReturnsSecurityRestoreApplyResult(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "restore-apply.db"))) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_restore", + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "restore runtime task", + }, + }) + if err != nil { + t.Fatalf("start task: %v", err) + } + taskID := startResult["task"].(map[string]any)["task_id"].(string) + err = storageService.RecoveryPointWriter().WriteRecoveryPoint(context.Background(), checkpoint.RecoveryPoint{ + RecoveryPointID: "rp_001", + TaskID: taskID, + Summary: "stored recovery point", + CreatedAt: "2026-04-08T10:00:00Z", + Objects: []string{"workspace/result.md"}, + }) + if err != nil { + t.Fatalf("write recovery point: %v", err) + } + + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-security-restore-apply"`), + Method: methodAgentSecurityRestoreApply, + 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) + 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) + } +} + +func TestDispatchReturnsNotepadUpdateResult(t *testing.T) { + server := newTestServer() + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-notepad-update"`), + Method: methodAgentNotepadUpdate, + 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) + 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) + if len(refreshGroups) != 2 || refreshGroups[0] != "later" || refreshGroups[1] != "upcoming" { + t.Fatalf("expected refresh_groups to include source and target buckets, got %+v", refreshGroups) + } +} + +func TestDispatchReturnsTaskArtifactList(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "artifact-list.db"))) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + err := storageService.ArtifactStore().SaveArtifacts(context.Background(), []storage.ArtifactRecord{{ + ArtifactID: "art_rpc_001", + TaskID: "task_rpc_001", + ArtifactType: "generated_doc", + Title: "rpc-artifact.md", + Path: "workspace/rpc-artifact.md", + MimeType: "text/markdown", + DeliveryType: "workspace_document", + DeliveryPayloadJSON: `{"path":"workspace/rpc-artifact.md","task_id":"task_rpc_001"}`, + CreatedAt: "2026-04-14T10:00:00Z", + }}) + if err != nil { + t.Fatalf("write artifact: %v", err) + } + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-artifact-list"`), + Method: methodAgentTaskArtifactList, + 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) + if len(items) != 1 || items[0]["artifact_id"] != "art_rpc_001" { + t.Fatalf("expected artifact list item, got %+v", items) + } +} + +func TestDispatchReturnsTaskArtifactOpen(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "artifact-open.db"))) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + err := storageService.ArtifactStore().SaveArtifacts(context.Background(), []storage.ArtifactRecord{{ + ArtifactID: "art_rpc_open_001", + TaskID: "task_rpc_open_001", + ArtifactType: "generated_doc", + Title: "rpc-open.md", + Path: "workspace/rpc-open.md", + MimeType: "text/markdown", + DeliveryType: "open_file", + DeliveryPayloadJSON: `{"path":"workspace/rpc-open.md","task_id":"task_rpc_open_001"}`, + CreatedAt: "2026-04-14T10:05:00Z", + }}) + if err != nil { + t.Fatalf("write artifact: %v", err) + } + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-artifact-open"`), + Method: methodAgentTaskArtifactOpen, + 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) + 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) + } +} + +func TestDispatchReturnsDeliveryOpenForArtifact(t *testing.T) { + server := newTestServer() + storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "delivery-open-artifact.db"))) + defer func() { _ = storageService.Close() }() + server.orchestrator.WithStorage(storageService) + err := storageService.ArtifactStore().SaveArtifacts(context.Background(), []storage.ArtifactRecord{{ + ArtifactID: "art_delivery_rpc_001", + TaskID: "task_delivery_rpc_001", + ArtifactType: "generated_doc", + Title: "delivery-rpc.md", + Path: "workspace/delivery-rpc.md", + MimeType: "text/markdown", + DeliveryType: "open_file", + DeliveryPayloadJSON: `{"path":"workspace/delivery-rpc.md","task_id":"task_delivery_rpc_001"}`, + CreatedAt: "2026-04-14T10:10:00Z", + }}) + if err != nil { + t.Fatalf("write artifact: %v", err) + } + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-delivery-open-artifact"`), + Method: methodAgentDeliveryOpen, + 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) + } +} + +func TestDispatchReturnsDeliveryOpenForTaskResult(t *testing.T) { + server := newTestServer() + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_delivery_rpc", + "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", + }, + }, + }) + if err != nil { + t.Fatalf("start task: %v", err) + } + taskID := startResult["task"].(map[string]any)["task_id"].(string) + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-delivery-open-task"`), + Method: methodAgentDeliveryOpen, + 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) + } +} + +func TestDispatchReturnsFormalTaskInspectorRunSourceErrors(t *testing.T) { + server := newTestServer() + pathPolicy, err := platform.NewLocalPathPolicy(filepath.Join(t.TempDir(), "rpc-task-inspector")) + if err != nil { + t.Fatalf("NewLocalPathPolicy returned error: %v", err) + } + server.orchestrator.WithTaskInspector(taskinspector.NewService(platform.NewLocalFileSystemAdapter(pathPolicy))) + tests := []struct { + name string + requestID string + targetSources []any + expectCode int + expectMessage string + }{ + {name: "missing source", requestID: "req-task-inspector-missing", targetSources: []any{"workspace/missing"}, expectCode: 1007007, expectMessage: "INSPECTION_SOURCE_NOT_FOUND"}, + {name: "outside workspace", requestID: "req-task-inspector-outside", targetSources: []any{"../outside"}, expectCode: 1004003, expectMessage: "WORKSPACE_BOUNDARY_DENIED"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := server.dispatch(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(fmt.Sprintf(`"%s"`, test.requestID)), + Method: methodAgentTaskInspectorRun, + Params: mustMarshal(t, map[string]any{"request_meta": rpcRequestMeta(test.requestID), "target_sources": test.targetSources}), + }) + errEnvelope, ok := response.(errorEnvelope) + if !ok { + t.Fatalf("expected error response envelope, got %#v", response) + } + if errEnvelope.Error.Code != test.expectCode || errEnvelope.Error.Message != test.expectMessage { + t.Fatalf("expected %s to map to formal rpc error, got %+v", test.expectMessage, errEnvelope.Error) + } + }) + } +} + +func TestNewServerSkipsDebugHTTPWhenDisabled(t *testing.T) { + seed := newTestServer() + server := NewServer(serviceconfig.RPCConfig{ + Transport: "named_pipe", + NamedPipeName: `\\.\pipe\cialloclaw-rpc-disabled`, + DebugHTTPAddress: "", + }, seed.orchestrator) + + if server.debugHTTPServer != nil { + t.Fatal("expected explicit empty debug HTTP address to disable the diagnostics server") + } +} diff --git a/services/local-service/internal/rpc/error_specs_test.go b/services/local-service/internal/rpc/error_specs_test.go index a25cd92f7..8e0ffd52e 100644 --- a/services/local-service/internal/rpc/error_specs_test.go +++ b/services/local-service/internal/rpc/error_specs_test.go @@ -59,3 +59,35 @@ func TestRPCErrorFromOrchestratorErrorFallsBackToInvalidParams(t *testing.T) { t.Fatalf("fallback rpc error = code:%d message:%s trace:%s", got.Code, got.Message, got.TraceID) } } + +func TestWrapOrchestratorResultMapsStructuredErrors(t *testing.T) { + tests := []struct { + name string + err error + wantCode int + wantMessage string + }{ + {name: "artifact not found", err: orchestrator.ErrArtifactNotFound, wantCode: 1005002, wantMessage: "ARTIFACT_NOT_FOUND"}, + {name: "storage query failed", err: orchestrator.ErrStorageQueryFailed, wantCode: 1005001, wantMessage: "SQLITE_WRITE_FAILED"}, + {name: "wrapped structured store", err: fmt.Errorf("settings snapshot write failed: %w", storage.ErrStructuredStoreUnavailable), wantCode: 1005001, wantMessage: "SQLITE_WRITE_FAILED"}, + {name: "recovery point not found", err: orchestrator.ErrRecoveryPointNotFound, wantCode: 1005006, wantMessage: "RECOVERY_POINT_NOT_FOUND"}, + {name: "stronghold access failed", err: orchestrator.ErrStrongholdAccessFailed, wantCode: 1005004, wantMessage: "STRONGHOLD_ACCESS_FAILED"}, + {name: "model provider unsupported", err: model.ErrModelProviderUnsupported, wantCode: 1008001, wantMessage: "MODEL_PROVIDER_NOT_FOUND"}, + {name: "model configuration invalid", err: model.ErrOpenAIEndpointRequired, wantCode: 1008002, wantMessage: "MODEL_NOT_ALLOWED"}, + {name: "model runtime unavailable", err: model.ErrOpenAIRequestTimeout, wantCode: 1008003, wantMessage: "MODEL_RUNTIME_UNAVAILABLE"}, + {name: "provider 5xx", err: &model.OpenAIHTTPStatusError{StatusCode: 503, Message: "upstream unavailable"}, wantCode: 1008003, wantMessage: "MODEL_RUNTIME_UNAVAILABLE"}, + {name: "tool output invalid", err: tools.ErrToolOutputInvalid, wantCode: 1003004, wantMessage: "TOOL_OUTPUT_INVALID"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, rpcErr := wrapOrchestratorResult(nil, test.err) + if rpcErr == nil { + t.Fatal("expected rpc error") + } + if rpcErr.Code != test.wantCode || rpcErr.Message != test.wantMessage { + t.Fatalf("rpc error = code:%d message:%s, want code:%d message:%s", rpcErr.Code, rpcErr.Message, test.wantCode, test.wantMessage) + } + }) + } +} diff --git a/services/local-service/internal/rpc/handlers_test.go b/services/local-service/internal/rpc/handlers_test.go new file mode 100644 index 000000000..9380ab020 --- /dev/null +++ b/services/local-service/internal/rpc/handlers_test.go @@ -0,0 +1,41 @@ +package rpc + +import "testing" + +func TestHandlerWrappersCoverRecommendationInspectorDashboardAndSecurityMethods(t *testing.T) { + server := newTestServer() + handlerCalls := []struct { + name string + invoke func() (any, *rpcError) + }{ + {name: "recommendation.get", invoke: func() (any, *rpcError) { return server.handleAgentRecommendationGet(map[string]any{}) }}, + {name: "recommendation.feedback.submit", invoke: func() (any, *rpcError) { return server.handleAgentRecommendationFeedbackSubmit(map[string]any{}) }}, + {name: "task_inspector.config.get", invoke: func() (any, *rpcError) { return server.handleAgentTaskInspectorConfigGet(nil) }}, + {name: "task_inspector.config.update", invoke: func() (any, *rpcError) { + return server.handleAgentTaskInspectorConfigUpdate(map[string]any{"task_sources": []any{"D:/workspace/todos"}, "inspection_interval": map[string]any{"unit": "minute", "value": 10}}) + }}, + {name: "task_inspector.run", invoke: func() (any, *rpcError) { return server.handleAgentTaskInspectorRun(map[string]any{}) }}, + {name: "notepad.list", invoke: func() (any, *rpcError) { return server.handleAgentNotepadList(map[string]any{}) }}, + {name: "notepad.convert_to_task", invoke: func() (any, *rpcError) { + return server.handleAgentNotepadConvertToTask(map[string]any{"item_id": "missing"}) + }}, + {name: "dashboard.overview.get", invoke: func() (any, *rpcError) { return server.handleAgentDashboardOverviewGet(map[string]any{}) }}, + {name: "dashboard.module.get", invoke: func() (any, *rpcError) { return server.handleAgentDashboardModuleGet(map[string]any{"module": "task"}) }}, + {name: "mirror.overview.get", invoke: func() (any, *rpcError) { return server.handleAgentMirrorOverviewGet(map[string]any{}) }}, + {name: "security.summary.get", invoke: func() (any, *rpcError) { return server.handleAgentSecuritySummaryGet(nil) }}, + {name: "security.pending.list", invoke: func() (any, *rpcError) { return server.handleAgentSecurityPendingList(map[string]any{}) }}, + {name: "security.respond", invoke: func() (any, *rpcError) { + return server.handleAgentSecurityRespond(map[string]any{"task_id": "missing", "decision": "approve"}) + }}, + {name: "settings.model.validate", invoke: func() (any, *rpcError) { return server.handleAgentSettingsModelValidate(map[string]any{}) }}, + } + for _, call := range handlerCalls { + data, rpcErr := call.invoke() + if data == nil && rpcErr == nil { + t.Fatalf("expected %s handler to return either data or rpc error", call.name) + } + if rpcErr != nil && rpcErr.TraceID == "" { + t.Fatalf("expected %s handler rpc error to include trace id, got %+v", call.name, rpcErr) + } + } +} diff --git a/services/local-service/internal/rpc/jsonrpc_test.go b/services/local-service/internal/rpc/jsonrpc_test.go new file mode 100644 index 000000000..5c9e56582 --- /dev/null +++ b/services/local-service/internal/rpc/jsonrpc_test.go @@ -0,0 +1,156 @@ +package rpc + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestJSONRPCDecodeHelpers(t *testing.T) { + req := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`1`), + Method: "agent.settings.update", + Params: mustMarshal(t, map[string]any{"request_meta": map[string]any{"trace_id": "trace_rpc_helpers"}, "enabled": true, "count": 3, "labels": []string{"a", "b"}}), + } + decoded, err := decodeRequest(strings.NewReader(string(mustMarshal(t, req)))) + if err != nil { + t.Fatalf("decodeRequest returned error: %v", err) + } + params, err := decodeParams(decoded.Params) + if err != nil { + t.Fatalf("decodeParams returned error: %v", err) + } + if requestTraceID(params) != "trace_rpc_helpers" || traceIDFromRequest(decoded.Params) != "trace_rpc_helpers" { + t.Fatalf("expected trace helpers to extract request trace ids, params=%+v", params) + } + if !boolValue(params, "enabled", false) || intValue(params, "count", 0) != 3 { + t.Fatalf("expected primitive decoders to round-trip values, params=%+v", params) + } + labels := stringSliceValue(params["labels"]) + if len(labels) != 2 || labels[1] != "b" { + t.Fatalf("expected stringSliceValue to decode labels, got %+v", labels) + } + if boolValue(nil, "enabled", false) || intValue(nil, "count", 0) != 0 || len(stringSliceValue(map[string]any{"labels": 7}["labels"])) != 0 { + t.Fatal("expected primitive decoders to handle nil and invalid inputs") + } +} + +func TestJSONRPCDecodeErrors(t *testing.T) { + tests := []struct { + name string + decode func() *rpcError + traceID string + }{ + { + name: "malformed request", + decode: func() *rpcError { + _, err := decodeRequest(strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"agent.settings.get","params":`)) + return err + }, + traceID: "trace_rpc_decode", + }, + { + name: "malformed params", + decode: func() *rpcError { + _, err := decodeParams(json.RawMessage(`{"broken":`)) + return err + }, + traceID: "trace_rpc_params", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.decode() + if err == nil { + t.Fatal("expected rpc error") + } + if err.Code != errInvalidParams || err.Message != "INVALID_PARAMS" || err.TraceID != test.traceID { + t.Fatalf("unexpected rpc error %+v", err) + } + }) + } +} + +func TestJSONRPCEnvelopeHelpers(t *testing.T) { + success := newSuccessEnvelope(nil, map[string]any{"ok": true}, "2026-04-08T10:00:00Z") + if success.JSONRPC != "2.0" || string(success.ID) != "null" || success.Result.Meta.ServerTime != "2026-04-08T10:00:00Z" { + t.Fatalf("unexpected success envelope %+v", success) + } + + failure := newErrorEnvelope(nil, &rpcError{ + Code: errMethodNotFound, + Message: "JSON_RPC_METHOD_NOT_FOUND", + Detail: "missing", + TraceID: "trace_missing", + }) + if failure.JSONRPC != "2.0" || string(failure.ID) != "null" || failure.Error.Data.TraceID != "trace_missing" { + t.Fatalf("unexpected error envelope %+v", failure) + } + + notification := newNotificationEnvelope("task.updated", map[string]any{"task_id": "task_001"}) + if notification.JSONRPC != "2.0" || notification.Method != "task.updated" { + t.Fatalf("unexpected notification envelope %+v", notification) + } +} + +func TestDispatchProtocolResponses(t *testing.T) { + server := newTestServer() + tests := []struct { + name string + request requestEnvelope + expectedCode int + expectedError string + expectedTrace string + }{ + { + name: "invalid jsonrpc version", + request: requestEnvelope{ + JSONRPC: "1.0", + ID: json.RawMessage(`"req-version"`), + Method: methodAgentSettingsGet, + }, + expectedCode: errInvalidParams, + expectedError: "INVALID_PARAMS", + expectedTrace: "trace_rpc_version", + }, + { + name: "method not found", + request: requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-missing"`), + Method: "agent.unknown.call", + Params: mustMarshal(t, map[string]any{"request_meta": map[string]any{"trace_id": "trace_unknown"}}), + }, + expectedCode: errMethodNotFound, + expectedError: "JSON_RPC_METHOD_NOT_FOUND", + expectedTrace: "trace_unknown", + }, + { + name: "invalid params", + request: requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-invalid-params"`), + Method: methodAgentTaskStart, + Params: json.RawMessage(`[]`), + }, + expectedCode: errInvalidParams, + expectedError: "INVALID_PARAMS", + expectedTrace: "trace_rpc_params", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := server.dispatch(test.request) + errEnvelope, ok := response.(errorEnvelope) + if !ok { + t.Fatalf("expected error response envelope, got %#v", response) + } + if errEnvelope.Error.Code != test.expectedCode || errEnvelope.Error.Message != test.expectedError || errEnvelope.Error.Data.TraceID != test.expectedTrace { + t.Fatalf("unexpected response %+v", errEnvelope) + } + }) + } +} diff --git a/services/local-service/internal/rpc/protocol_dto_test_helpers_test.go b/services/local-service/internal/rpc/protocol_dto_test_helpers_test.go deleted file mode 100644 index b8bb7053d..000000000 --- a/services/local-service/internal/rpc/protocol_dto_test_helpers_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package rpc - -import "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" - -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 submitInputForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { - response, err := s.SubmitInput(orchestrator.SubmitInputRequestFromParams(params)) - if err != nil { - return nil, err - } - return response.Map(), nil -} - -func taskDetailGetForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { - response, err := s.TaskDetailGet(orchestrator.TaskDetailGetRequestFromParams(params)) - if err != nil { - return nil, err - } - return response.Map(), nil -} diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index ee578a58e..a6c6547f1 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "regexp" "strings" "testing" @@ -84,6 +85,38 @@ func TestAgentTaskStartDTOOmitsUnsupportedIntentField(t *testing.T) { } } +func TestStableMethodRegistryDispatchMatrix(t *testing.T) { + server := newTestServer() + expectedDecoders := map[string]func(json.RawMessage) (map[string]any, *rpcError){ + methodAgentInputSubmit: decodeAgentInputSubmitParams, + methodAgentTaskStart: decodeAgentTaskStartParams, + methodAgentTaskConfirm: decodeParamsRequiringRequestMeta, + methodAgentTaskControl: decodeParamsRequiringRequestMeta, + methodAgentTaskDetailGet: decodeAgentTaskDetailGetParams, + methodAgentTaskInspectorConfigGet: decodeParamsRequiringRequestMeta, + methodAgentTaskInspectorRun: decodeParamsRequiringRequestMeta, + methodAgentDeliveryOpen: decodeParamsRequiringRequestMeta, + methodAgentSettingsGet: decodeParamsRequiringRequestMeta, + methodAgentPluginDetailGet: decodeParams, + } + + for _, method := range server.stableMethodRegistry() { + if method.Handle == nil { + t.Fatalf("expected registered handler for %s", method.Name) + } + if method.Decode == nil { + t.Fatalf("expected decoder for %s", method.Name) + } + expectedDecode, ok := expectedDecoders[method.Name] + if !ok { + continue + } + if reflect.ValueOf(method.Decode).Pointer() != reflect.ValueOf(expectedDecode).Pointer() { + t.Fatalf("unexpected decoder for %s", method.Name) + } + } +} + func TestAgentInputSubmitDTORejectsMissingStableFields(t *testing.T) { _, rpcErr := decodeAgentInputSubmitParams(mustMarshal(t, map[string]any{ "input": map[string]any{ diff --git a/services/local-service/internal/rpc/rpc_test_helpers_test.go b/services/local-service/internal/rpc/rpc_test_helpers_test.go new file mode 100644 index 000000000..8d16068a0 --- /dev/null +++ b/services/local-service/internal/rpc/rpc_test_helpers_test.go @@ -0,0 +1,247 @@ +package rpc + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/audit" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/checkpoint" + serviceconfig "github.com/cialloclaw/cialloclaw/services/local-service/internal/config" + contextsvc "github.com/cialloclaw/cialloclaw/services/local-service/internal/context" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/delivery" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/execution" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/intent" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/memory" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/model" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/platform" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/plugin" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/risk" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/runengine" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/tools" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/tools/builtin" + "github.com/cialloclaw/cialloclaw/services/local-service/internal/tools/sidecarclient" +) + +func rpcRequestMeta(traceID string) map[string]any { + return map[string]any{ + "trace_id": traceID, + "client_time": "2026-05-10T00:00:00Z", + } +} + +type stubLoopModelClient struct { + toolResult model.ToolCallResult + generateToolWait chan struct{} + generateToolSeen chan struct{} +} + +// selectiveWaitLoopModelClient only applies the blocking tool-call gate to one +// task so stream-serialization tests can distinguish per-task locking from +// unrelated concurrent requests. +type selectiveWaitLoopModelClient struct { + stubLoopModelClient + blockedTaskID string +} + +func (s *stubLoopModelClient) GenerateText(_ context.Context, request model.GenerateTextRequest) (model.GenerateTextResponse, error) { + return model.GenerateTextResponse{ + TaskID: request.TaskID, + RunID: request.RunID, + RequestID: "req_loop_text", + Provider: "openai_responses", + ModelID: "gpt-5.4", + OutputText: "loop fallback output", + }, nil +} + +func (s *stubLoopModelClient) GenerateToolCalls(_ context.Context, request model.ToolCallRequest) (model.ToolCallResult, error) { + if s.generateToolSeen != nil { + select { + case <-s.generateToolSeen: + default: + close(s.generateToolSeen) + } + } + if s.generateToolWait != nil { + <-s.generateToolWait + } + result := s.toolResult + if strings.TrimSpace(result.OutputText) == "" && len(result.ToolCalls) == 0 { + result.OutputText = request.Input + } + if result.RequestID == "" { + result.RequestID = "req_loop_tools" + } + if result.Provider == "" { + result.Provider = "openai_responses" + } + if result.ModelID == "" { + result.ModelID = "gpt-5.4" + } + return result, nil +} + +func (s *selectiveWaitLoopModelClient) GenerateToolCalls(ctx context.Context, request model.ToolCallRequest) (model.ToolCallResult, error) { + if strings.TrimSpace(s.blockedTaskID) == "" || request.TaskID == s.blockedTaskID { + return s.stubLoopModelClient.GenerateToolCalls(ctx, request) + } + + result := s.toolResult + if strings.TrimSpace(result.OutputText) == "" && len(result.ToolCalls) == 0 { + result.OutputText = request.Input + } + if result.RequestID == "" { + result.RequestID = "req_loop_tools" + } + if result.Provider == "" { + result.Provider = "openai_responses" + } + if result.ModelID == "" { + result.ModelID = "gpt-5.4" + } + return result, nil +} + +type testStorageAdapter struct { + databasePath string +} + +type stubExecutionCapability struct { + result tools.CommandExecutionResult + err error +} + +func (s stubExecutionCapability) RunCommand(_ context.Context, _ string, _ []string, _ string) (tools.CommandExecutionResult, error) { + if s.err != nil { + return tools.CommandExecutionResult{}, s.err + } + return s.result, nil +} + +func (a testStorageAdapter) DatabasePath() string { + return a.databasePath +} + +func (a testStorageAdapter) SecretStorePath() string { + if a.databasePath == "" { + return "" + } + return a.databasePath + ".stronghold" +} + +func newTestServer() *Server { + server, _, _ := newTestServerWithDependencies(nil) + return server +} + +func newTestServerWithModelClient(client model.Client) *Server { + server, _, _ := newTestServerWithDependencies(client) + return server +} + +func newTestServerWithDependencies(client model.Client) (*Server, *tools.Registry, *plugin.Service) { + toolRegistry := tools.NewRegistry() + _ = builtin.RegisterBuiltinTools(toolRegistry) + _ = sidecarclient.RegisterPlaywrightTools(toolRegistry) + _ = sidecarclient.RegisterOCRTools(toolRegistry) + _ = sidecarclient.RegisterMediaTools(toolRegistry) + toolExecutor := tools.NewToolExecutor(toolRegistry) + pathPolicy, _ := platform.NewLocalPathPolicy(filepath.Join("workspace", "rpc-test")) + fileSystem := platform.NewLocalFileSystemAdapter(pathPolicy) + pluginService := plugin.NewService() + executionService := execution.NewService( + fileSystem, + stubExecutionCapability{result: tools.CommandExecutionResult{Stdout: "ok", ExitCode: 0}}, + sidecarclient.NewNoopPlaywrightSidecarClient(), + sidecarclient.NewNoopOCRWorkerClient(), + sidecarclient.NewNoopMediaWorkerClient(), + sidecarclient.NewNoopScreenCaptureClient(), + model.NewService(serviceconfig.ModelConfig{Provider: "openai_responses", ModelID: "gpt-5.4", Endpoint: "https://api.openai.com/v1/responses"}, client), + audit.NewService(), + checkpoint.NewService(), + delivery.NewService(), + toolRegistry, + toolExecutor, + pluginService, + ) + orch := orchestrator.NewService( + contextsvc.NewService(), + intent.NewService(), + runengine.NewEngine(), + delivery.NewService(), + memory.NewService(), + risk.NewService(), + model.NewService(serviceconfig.ModelConfig{ + Provider: "openai_responses", + ModelID: "gpt-5.4", + Endpoint: "https://api.openai.com/v1/responses", + }), + toolRegistry, + pluginService, + ).WithExecutor(executionService) + + server := NewServer(serviceconfig.RPCConfig{ + Transport: "named_pipe", + NamedPipeName: `\\.\pipe\cialloclaw-rpc-test`, + DebugHTTPAddress: ":0", + }, orch) + server.now = func() time.Time { + return time.Date(2026, 4, 8, 10, 0, 0, 0, time.UTC) + } + return server, toolRegistry, pluginService +} + +func mustMarshal(t *testing.T, value any) json.RawMessage { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal request params: %v", err) + } + return encoded +} + +func numericValue(t *testing.T, value any) int { + t.Helper() + switch typed := value.(type) { + case int: + return typed + case int32: + return int(typed) + case int64: + return int(typed) + case float64: + return int(typed) + default: + t.Fatalf("expected numeric value, got %#v", value) + return 0 + } +} + +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 submitInputForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { + response, err := s.SubmitInput(orchestrator.SubmitInputRequestFromParams(params)) + if err != nil { + return nil, err + } + return response.Map(), nil +} + +func taskDetailGetForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { + response, err := s.TaskDetailGet(orchestrator.TaskDetailGetRequestFromParams(params)) + if err != nil { + return nil, err + } + return response.Map(), nil +} diff --git a/services/local-service/internal/rpc/server_stream_coordination_test.go b/services/local-service/internal/rpc/server_stream_coordination_test.go new file mode 100644 index 000000000..97fc110c1 --- /dev/null +++ b/services/local-service/internal/rpc/server_stream_coordination_test.go @@ -0,0 +1,761 @@ +// RPC stream coordination tests verify task ownership, serialization, and replay order. +package rpc + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "sync" + "testing" + "time" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/model" +) + +func TestHandleStreamConnSerializesTaskStartingRequestsOnSharedConnection(t *testing.T) { + testCases := []struct { + name string + method string + firstParams map[string]any + secondParams map[string]any + }{ + { + name: "input submit", + method: "agent.input.submit", + firstParams: map[string]any{ + "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", + "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", + "input_mode": "text", + }, + "context": map[string]any{}, + }, + }, + { + name: "task start", + method: "agent.task.start", + firstParams: map[string]any{ + "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", + "input": map[string]any{ + "type": "text_selection", + "text": "second selection", + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + server := newTestServer() + firstStarted := make(chan struct{}) + releaseFirst := make(chan struct{}) + var callCount int + var callMu sync.Mutex + + server.handlers[testCase.method] = func(_ map[string]any) (any, *rpcError) { + callMu.Lock() + callCount++ + currentCall := callCount + callMu.Unlock() + + if currentCall == 1 { + select { + case <-firstStarted: + default: + close(firstStarted) + } + <-releaseFirst + } + + return map[string]any{ + "task": map[string]any{ + "task_id": fmt.Sprintf("task_serial_%d", currentCall), + }, + }, nil + } + + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + firstRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-starting-1"`), + Method: testCase.method, + Params: mustMarshal(t, testCase.firstParams), + } + secondRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-starting-2"`), + Method: testCase.method, + Params: mustMarshal(t, testCase.secondParams), + } + + if err := encoder.Encode(firstRequest); err != nil { + t.Fatalf("encode first request: %v", err) + } + + select { + case <-firstStarted: + case <-time.After(500 * time.Millisecond): + t.Fatal("expected first task-starting request to begin running") + } + + if err := encoder.Encode(secondRequest); err != nil { + t.Fatalf("encode second request: %v", err) + } + + type decodeResult struct { + envelope map[string]any + err error + } + firstResponseCh := make(chan decodeResult, 1) + go func() { + var envelope map[string]any + err := decoder.Decode(&envelope) + firstResponseCh <- decodeResult{envelope: envelope, err: err} + }() + + select { + case result := <-firstResponseCh: + if result.err != nil { + t.Fatalf("expected no response before the first task-starting request finishes, got %v", result.err) + } + t.Fatalf("expected second task-starting request to stay queued until the first finishes, got %+v", result.envelope) + case <-time.After(250 * time.Millisecond): + } + + close(releaseFirst) + + seenResponses := map[string]bool{} + select { + case result := <-firstResponseCh: + if result.err != nil { + t.Fatalf("decode first serialized response envelope: %v", result.err) + } + id, _ := result.envelope["id"].(string) + if id == "req-task-starting-1" || id == "req-task-starting-2" { + seenResponses[id] = true + } + case <-time.After(1 * time.Second): + t.Fatal("expected first queued task-starting request to finish after release") + } + + if err := right.SetReadDeadline(time.Now().Add(1 * time.Second)); err != nil { + t.Fatalf("set response deadline: %v", err) + } + for len(seenResponses) < 2 { + var envelope map[string]any + if err := decoder.Decode(&envelope); err != nil { + t.Fatalf("decode serialized response envelope: %v", err) + } + id, _ := envelope["id"].(string) + if id == "req-task-starting-1" || id == "req-task-starting-2" { + seenResponses[id] = true + } + } + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear response deadline: %v", err) + } + }) + } +} + +func TestHandleStreamConnReplaysLateTaskNotificationsBeforeQueuedSameTaskFollowUp(t *testing.T) { + server := newTestServer() + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_late_task_replay", + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "queue notifications for shared stream replay", + }, + }) + if err != nil { + t.Fatalf("seed task.start: %v", err) + } + taskID := startResult["task"].(map[string]any)["task_id"].(string) + notifications, err := server.orchestrator.PendingNotifications(taskID) + if err != nil || len(notifications) == 0 { + t.Fatalf("expected seeded task to queue notifications, err=%v notifications=%+v", err, notifications) + } + + firstReturned := make(chan struct{}) + server.handlers["agent.task.start"] = func(_ map[string]any) (any, *rpcError) { + select { + case <-firstReturned: + default: + close(firstReturned) + } + return map[string]any{ + "task": map[string]any{ + "task_id": taskID, + }, + }, nil + } + server.handlers["test.followup.task"] = func(params map[string]any) (any, *rpcError) { + return map[string]any{ + "task": map[string]any{ + "task_id": stringValue(params, "task_id", ""), + }, + }, nil + } + + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + firstRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-late-task-starter"`), + Method: "agent.task.start", + Params: mustMarshal(t, map[string]any{ + "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", + }, + }), + } + if err := encoder.Encode(firstRequest); err != nil { + t.Fatalf("encode first late-task response request: %v", err) + } + + select { + case <-firstReturned: + case <-time.After(500 * time.Millisecond): + t.Fatal("expected first task-starting request to finish dispatch") + } + + secondRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-late-task-followup"`), + Method: "test.followup.task", + Params: mustMarshal(t, map[string]any{ + "task_id": taskID, + }), + } + if err := encoder.Encode(secondRequest); err != nil { + t.Fatalf("encode same-task follow-up request: %v", err) + } + + if err := right.SetReadDeadline(time.Now().Add(1500 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + defer func() { + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear read deadline: %v", err) + } + }() + + var firstEnvelope map[string]any + if err := decoder.Decode(&firstEnvelope); err != nil { + t.Fatalf("decode first response envelope: %v", err) + } + if firstID, _ := firstEnvelope["id"].(string); firstID != "req-late-task-starter" { + t.Fatalf("expected first envelope to be the starter response, got %+v", firstEnvelope) + } + + notificationBeforeFollowUp := false + followUpResponseSeen := false + for index := 0; index < 12; index++ { + var envelope map[string]any + if err := decoder.Decode(&envelope); err != nil { + t.Fatalf("decode shared stream envelope: %v", err) + } + if envelopeID, _ := envelope["id"].(string); envelopeID == "req-late-task-followup" { + followUpResponseSeen = true + break + } + if method, _ := envelope["method"].(string); method != "" { + notificationBeforeFollowUp = true + } + } + if !followUpResponseSeen { + t.Fatal("expected follow-up response to arrive on the shared stream") + } + if !notificationBeforeFollowUp { + t.Fatal("expected buffered notifications for the started task to replay before the queued same-task follow-up response") + } +} + +func TestHandleStreamConnTaskListDoesNotStealBufferedNotifications(t *testing.T) { + server := newTestServer() + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_task_list_replay_owner", + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "queue notifications for task.list replay ownership", + }, + }) + if err != nil { + t.Fatalf("seed task.start: %v", err) + } + taskID := startResult["task"].(map[string]any)["task_id"].(string) + notifications, err := server.orchestrator.PendingNotifications(taskID) + if err != nil || len(notifications) == 0 { + t.Fatalf("expected seeded task to queue notifications, err=%v notifications=%+v", err, notifications) + } + + listStarted := make(chan struct{}) + allowListReturn := make(chan struct{}) + server.handlers["agent.task.list"] = func(_ map[string]any) (any, *rpcError) { + select { + case <-listStarted: + default: + close(listStarted) + } + <-allowListReturn + return map[string]any{ + "items": []any{ + map[string]any{"task_id": taskID}, + }, + }, nil + } + server.handlers["test.followup.task"] = func(params map[string]any) (any, *rpcError) { + return map[string]any{ + "task": map[string]any{ + "task_id": stringValue(params, "task_id", ""), + }, + }, nil + } + + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + if err := encoder.Encode(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-list-owned"`), + Method: "agent.task.list", + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_list_owned"), + }), + }); err != nil { + t.Fatalf("encode task.list request: %v", err) + } + + select { + case <-listStarted: + case <-time.After(500 * time.Millisecond): + t.Fatal("expected task.list request to start dispatch") + } + + if err := encoder.Encode(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-list-followup"`), + Method: "test.followup.task", + Params: mustMarshal(t, map[string]any{ + "task_id": taskID, + }), + }); err != nil { + t.Fatalf("encode follow-up request: %v", err) + } + close(allowListReturn) + + if err := right.SetReadDeadline(time.Now().Add(1500 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + defer func() { + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear read deadline: %v", err) + } + }() + + taskListResponseSeen := false + followUpResponseSeen := false + notificationAfterFollowUp := false + for index := 0; index < 16; index++ { + var envelope map[string]any + if err := decoder.Decode(&envelope); err != nil { + t.Fatalf("decode shared stream envelope: %v", err) + } + if envelopeID, _ := envelope["id"].(string); envelopeID == "req-task-list-owned" { + taskListResponseSeen = true + } else if envelopeID, _ := envelope["id"].(string); envelopeID == "req-task-list-followup" { + followUpResponseSeen = true + } else if method, _ := envelope["method"].(string); method != "" { + if !followUpResponseSeen { + t.Fatalf("expected task.list not to replay %s before the follow-up response, got %+v", method, envelope) + } + notificationAfterFollowUp = true + } + if taskListResponseSeen && followUpResponseSeen && notificationAfterFollowUp { + break + } + } + if !taskListResponseSeen { + t.Fatal("expected task.list response to arrive on the shared stream") + } + if !followUpResponseSeen { + t.Fatal("expected follow-up response to arrive on the shared stream") + } + if !notificationAfterFollowUp { + t.Fatal("expected the owning follow-up request to replay buffered notifications after its response") + } +} + +func TestStreamTaskCoordinatorReleasesIdleTaskLocks(t *testing.T) { + coordinator := newStreamTaskCoordinator() + coordinator.withTaskLocks(map[string]bool{ + "task_cleanup": true, + }, func() {}) + + coordinator.mu.Lock() + defer coordinator.mu.Unlock() + if len(coordinator.locks) != 0 { + t.Fatalf("expected idle task locks to be released, got %+v", coordinator.locks) + } +} + +func TestHandleStreamConnKeepsQueuedReadsResponsiveWhileLoopTaskRuns(t *testing.T) { + modelClient := &selectiveWaitLoopModelClient{ + stubLoopModelClient: stubLoopModelClient{ + toolResult: model.ToolCallResult{ + OutputText: "Concurrent stream finished.", + }, + generateToolWait: make(chan struct{}), + generateToolSeen: make(chan struct{}), + }, + } + server := newTestServerWithModelClient(modelClient) + + startTask := func(sessionID string) string { + t.Helper() + result, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": sessionID, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "inspect this workspace", + }, + }) + if err != nil { + t.Fatalf("seed task.start for %s: %v", sessionID, err) + } + return result["task"].(map[string]any)["task_id"].(string) + } + + taskA := startTask("sess_pipe_queue_a") + taskB := startTask("sess_pipe_queue_b") + modelClient.blockedTaskID = taskA + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen loopback: %v", err) + } + defer listener.Close() + + acceptDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + acceptDone <- err + return + } + server.handleStreamConn(conn) + acceptDone <- nil + }() + + right, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("dial loopback: %v", err) + } + defer func() { + _ = right.Close() + select { + case err := <-acceptDone: + if err != nil && !errors.Is(err, net.ErrClosed) { + t.Fatalf("accept loopback: %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("expected loopback stream to shut down") + } + }() + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + confirmRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-loop-blocked"`), + Method: "agent.task.confirm", + Params: mustMarshal(t, map[string]any{ + "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{}, + }, + }), + } + if err := encoder.Encode(confirmRequest); err != nil { + t.Fatalf("encode blocked confirm request: %v", err) + } + + select { + case <-modelClient.generateToolSeen: + case <-time.After(500 * time.Millisecond): + t.Fatal("expected blocked loop task to start model execution") + } + + detailRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-detail-queued"`), + Method: "agent.task.detail.get", + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_detail_loop_blocked"), + "task_id": taskB, + }), + } + if err := encoder.Encode(detailRequest); err != nil { + t.Fatalf("encode queued detail request: %v", err) + } + + if err := right.SetReadDeadline(time.Now().Add(750 * time.Millisecond)); err != nil { + t.Fatalf("set queued response deadline: %v", err) + } + defer func() { + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear queued response deadline: %v", err) + } + }() + + queuedResponseSeen := false + for index := 0; index < 12; index++ { + var envelope map[string]any + if err := decoder.Decode(&envelope); err != nil { + break + } + responseID, _ := envelope["id"].(string) + if responseID != "req-task-detail-queued" { + continue + } + result, ok := envelope["result"].(map[string]any) + if !ok { + t.Fatalf("expected queued detail success envelope, got %+v", envelope) + } + data, ok := result["data"].(map[string]any) + if !ok { + t.Fatalf("expected queued detail response payload, got %+v", envelope) + } + task, ok := data["task"].(map[string]any) + if !ok || task["task_id"] != taskB { + t.Fatalf("expected queued detail response for %s, got %+v", taskB, envelope) + } + queuedResponseSeen = true + break + } + + close(modelClient.generateToolWait) + + if !queuedResponseSeen { + t.Fatal("expected queued task detail request to complete before the blocked loop response") + } +} + +func TestHandleStreamConnSerializesConcurrentRequestsForSameTask(t *testing.T) { + modelClient := &selectiveWaitLoopModelClient{ + stubLoopModelClient: stubLoopModelClient{ + toolResult: model.ToolCallResult{ + OutputText: "Same-task stream finished.", + }, + generateToolWait: make(chan struct{}), + generateToolSeen: make(chan struct{}), + }, + } + server := newTestServerWithModelClient(modelClient) + + result, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_pipe_same_task", + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "inspect this workspace", + }, + }) + if err != nil { + t.Fatalf("seed task.start: %v", err) + } + taskID := result["task"].(map[string]any)["task_id"].(string) + modelClient.blockedTaskID = taskID + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen loopback: %v", err) + } + defer listener.Close() + + acceptDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + acceptDone <- err + return + } + server.handleStreamConn(conn) + acceptDone <- nil + }() + + right, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("dial loopback: %v", err) + } + defer func() { + _ = right.Close() + select { + case err := <-acceptDone: + if err != nil && !errors.Is(err, net.ErrClosed) { + t.Fatalf("accept loopback: %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("expected loopback stream to shut down") + } + }() + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + envelopeCh := make(chan map[string]any, 32) + decodeErrCh := make(chan error, 1) + go func() { + for { + var envelope map[string]any + if err := decoder.Decode(&envelope); err != nil { + decodeErrCh <- err + return + } + envelopeCh <- envelope + } + }() + confirmRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-loop-same-task"`), + Method: "agent.task.confirm", + Params: mustMarshal(t, map[string]any{ + "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{}, + }, + }), + } + if err := encoder.Encode(confirmRequest); err != nil { + t.Fatalf("encode blocked confirm request: %v", err) + } + + select { + case <-modelClient.generateToolSeen: + case <-time.After(500 * time.Millisecond): + t.Fatal("expected blocked same-task loop to start model execution") + } + + detailRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-detail-same-task"`), + Method: "agent.task.detail.get", + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_detail_same_task"), + "task_id": taskID, + }), + } + if err := encoder.Encode(detailRequest); err != nil { + t.Fatalf("encode same-task detail request: %v", err) + } + + detailResponseSeenEarly := false + earlyWindow := time.After(250 * time.Millisecond) + for !detailResponseSeenEarly { + select { + case envelope := <-envelopeCh: + responseID, _ := envelope["id"].(string) + if responseID == "req-task-detail-same-task" { + detailResponseSeenEarly = true + } + case err := <-decodeErrCh: + t.Fatalf("decode same-task early envelope: %v", err) + case <-earlyWindow: + goto afterEarlyWindow + } + } + +afterEarlyWindow: + + if detailResponseSeenEarly { + t.Fatal("expected same-task detail request to wait until the blocked loop request finishes") + } + + close(modelClient.generateToolWait) + detailResponseSeen := false + postUnblockDeadline := time.After(3 * time.Second) + for !detailResponseSeen { + select { + case envelope := <-envelopeCh: + responseID, _ := envelope["id"].(string) + if responseID == "req-task-detail-same-task" { + detailResponseSeen = true + } + case err := <-decodeErrCh: + t.Fatalf("decode same-task post-unblock envelope: %v", err) + case <-postUnblockDeadline: + t.Fatal("expected same-task detail response after the blocked loop request completed") + } + } +} diff --git a/services/local-service/internal/rpc/server_stream_notifications_test.go b/services/local-service/internal/rpc/server_stream_notifications_test.go new file mode 100644 index 000000000..206502484 --- /dev/null +++ b/services/local-service/internal/rpc/server_stream_notifications_test.go @@ -0,0 +1,663 @@ +// RPC stream notification tests verify runtime event delivery and replay rules. +package rpc + +import ( + "encoding/json" + "net" + "strings" + "testing" + "time" + + "github.com/cialloclaw/cialloclaw/services/local-service/internal/model" +) + +// TestHandleStreamConnEmitsApprovalNotifications verifies that approval notifications +// are emitted on the stream connection after task confirmation enters waiting_auth. +func TestHandleStreamConnEmitsApprovalNotifications(t *testing.T) { + server := newTestServer() + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_demo", + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "请生成一个文件版本", + }, + }) + if err != nil { + t.Fatalf("seed task.start: %v", err) + } + taskID := startResult["task"].(map[string]any)["task_id"].(string) + if startResult["task"].(map[string]any)["status"] != "confirming_intent" { + t.Fatalf("expected seeded task to wait for confirm, got %+v", startResult["task"]) + } + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-1"`), + Method: "agent.task.confirm", + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_confirm_approval"), + "task_id": taskID, + "confirmed": false, + "corrected_intent": map[string]any{ + "name": "write_file", + "arguments": map[string]any{ + "require_authorization": true, + "target_path": "workspace_document", + }, + }, + }), + } + + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode request: %v", err) + } + + var response successEnvelope + 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" { + t.Fatalf("expected waiting_auth task status in response") + } + + if err := right.SetReadDeadline(time.Now().Add(200 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + + seenApprovalPending := false + for index := 0; index < 8; index++ { + var notification notificationEnvelope + if err := decoder.Decode(¬ification); err != nil { + break + } + if notification.Method == "approval.pending" { + seenApprovalPending = true + } + } + + if !seenApprovalPending { + t.Fatal("expected approval.pending notification to be emitted on stream connection") + } +} + +func TestHandleStreamConnEmitsLoopLifecycleNotifications(t *testing.T) { + server := newTestServer() + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_loop_notify", + "source": "floating_ball", + "trigger": "hover_text_input", + "input": map[string]any{ + "type": "text", + "text": "Inspect the workspace and answer.", + }, + "intent": map[string]any{ + "name": "summarize", + "arguments": map[string]any{ + "style": "key_points", + }, + }, + }) + if err != nil { + t.Fatalf("seed task.start: %v", err) + } + taskID := startResult["task"].(map[string]any)["task_id"].(string) + if _, ok := server.orchestrator.RunEngine().EmitRuntimeNotification(taskID, "loop.round.completed", map[string]any{ + "loop_round": 1, + "stop_reason": "completed", + }); !ok { + t.Fatal("expected runtime notification injection to succeed") + } + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-task-detail"`), + Method: "agent.task.detail.get", + Params: mustMarshal(t, map[string]any{ + "request_meta": rpcRequestMeta("trace_task_detail_loop_notify"), + "task_id": taskID, + }), + } + + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode request: %v", err) + } + + var response successEnvelope + 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 { + t.Fatalf("expected task detail response for %s, got %+v", taskID, response) + } + + if err := right.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + seenLoopNotification := false + for index := 0; index < 12; index++ { + var notification notificationEnvelope + if err := decoder.Decode(¬ification); err != nil { + break + } + if strings.HasPrefix(notification.Method, "loop.") { + seenLoopNotification = true + break + } + } + if !seenLoopNotification { + t.Fatal("expected loop.* notification to be emitted on stream connection") + } +} + +func TestHandleStreamConnStreamsLoopLifecycleNotificationsBeforeResponse(t *testing.T) { + modelClient := &stubLoopModelClient{ + toolResult: model.ToolCallResult{ + OutputText: "Loop runtime finished in-flight.", + }, + generateToolWait: make(chan struct{}), + generateToolSeen: make(chan struct{}), + } + server := newTestServerWithModelClient(modelClient) + startResult, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": "sess_loop_stream", + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "inspect this workspace", + }, + }) + if err != nil { + t.Fatalf("seed task.start: %v", err) + } + taskID := startResult["task"].(map[string]any)["task_id"].(string) + if startResult["task"].(map[string]any)["status"] != "confirming_intent" { + t.Fatalf("expected seeded task to wait for confirm, got %+v", startResult["task"]) + } + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-loop-stream"`), + Method: "agent.task.confirm", + Params: mustMarshal(t, map[string]any{ + "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{}, + }, + }), + } + + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode request: %v", err) + } + if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + + var firstEnvelope map[string]any + if err := decoder.Decode(&firstEnvelope); err != nil { + t.Fatalf("decode first envelope: %v", err) + } + if method, _ := firstEnvelope["method"].(string); !strings.HasPrefix(method, "loop.") { + t.Fatalf("expected first streamed envelope to be loop.* notification, got %+v", firstEnvelope) + } + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear read deadline: %v", err) + } + + close(modelClient.generateToolWait) + + if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("set response deadline: %v", err) + } + responseSeen := false + for index := 0; index < 8; index++ { + var envelope map[string]any + if err := decoder.Decode(&envelope); err != nil { + t.Fatalf("decode response envelope: %v", err) + } + if envelope["id"] == nil { + continue + } + result, ok := envelope["result"].(map[string]any) + if !ok { + t.Fatalf("expected success result envelope, got %+v", envelope) + } + data, ok := result["data"].(map[string]any) + if !ok { + t.Fatalf("expected response data payload, got %+v", envelope) + } + task, ok := data["task"].(map[string]any) + if !ok || task["status"] != "completed" { + t.Fatalf("expected completed task response, got %+v", envelope) + } + responseSeen = true + break + } + if !responseSeen { + t.Fatal("expected final response after streamed loop notifications") + } +} + +func TestHandleStreamConnStreamsLoopLifecycleNotificationsBeforeResponseForSubmitInput(t *testing.T) { + modelClient := &stubLoopModelClient{ + toolResult: model.ToolCallResult{ + OutputText: "Loop runtime finished from input.submit.", + }, + generateToolWait: make(chan struct{}), + generateToolSeen: make(chan struct{}), + } + server := newTestServerWithModelClient(modelClient) + + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-input-submit-loop-stream"`), + Method: "agent.input.submit", + Params: mustMarshal(t, map[string]any{ + "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", + "input_mode": "text", + }, + "context": map[string]any{}, + "options": map[string]any{ + "confirm_required": false, + }, + }), + } + + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode request: %v", err) + } + if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + + var firstEnvelope map[string]any + if err := decoder.Decode(&firstEnvelope); err != nil { + t.Fatalf("decode first envelope: %v", err) + } + if method, _ := firstEnvelope["method"].(string); !strings.HasPrefix(method, "loop.") { + t.Fatalf("expected first streamed envelope to be loop.* notification, got %+v", firstEnvelope) + } + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear read deadline: %v", err) + } + + close(modelClient.generateToolWait) + + if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("set response deadline: %v", err) + } + responseSeen := false + for index := 0; index < 8; index++ { + var envelope map[string]any + if err := decoder.Decode(&envelope); err != nil { + t.Fatalf("decode response envelope: %v", err) + } + if envelope["id"] == nil { + continue + } + result, ok := envelope["result"].(map[string]any) + if !ok { + t.Fatalf("expected success result envelope, got %+v", envelope) + } + data, ok := result["data"].(map[string]any) + if !ok { + t.Fatalf("expected response data payload, got %+v", envelope) + } + task, ok := data["task"].(map[string]any) + if !ok || task["status"] != "completed" { + t.Fatalf("expected completed task response, got %+v", envelope) + } + responseSeen = true + break + } + if !responseSeen { + t.Fatal("expected final response after streamed loop notifications") + } +} + +func TestHandleStreamConnDoesNotReplayStreamedRuntimeNotificationsAfterResponse(t *testing.T) { + modelClient := &stubLoopModelClient{ + toolResult: model.ToolCallResult{ + OutputText: "Loop runtime should not replay live events.", + }, + generateToolWait: make(chan struct{}), + generateToolSeen: make(chan struct{}), + } + server := newTestServerWithModelClient(modelClient) + + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-loop-no-replay"`), + Method: "agent.input.submit", + Params: mustMarshal(t, map[string]any{ + "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", + "input_mode": "text", + }, + "context": map[string]any{}, + "options": map[string]any{ + "confirm_required": false, + }, + }), + } + + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode request: %v", err) + } + if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("set first notification deadline: %v", err) + } + + var firstEnvelope notificationEnvelope + if err := decoder.Decode(&firstEnvelope); err != nil { + t.Fatalf("decode first notification: %v", err) + } + if !strings.HasPrefix(firstEnvelope.Method, "loop.") { + t.Fatalf("expected first streamed envelope to be loop.* notification, got %+v", firstEnvelope) + } + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear read deadline: %v", err) + } + + close(modelClient.generateToolWait) + + if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("set response deadline: %v", err) + } + responseSeen := false + for index := 0; index < 8; index++ { + var envelope map[string]any + if err := decoder.Decode(&envelope); err != nil { + t.Fatalf("decode response envelope: %v", err) + } + if envelope["id"] == nil { + continue + } + responseSeen = true + break + } + if !responseSeen { + t.Fatal("expected final response after streamed loop notifications") + } + + if err := right.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil { + t.Fatalf("set replay deadline: %v", err) + } + for { + var envelope notificationEnvelope + if err := decoder.Decode(&envelope); err != nil { + break + } + if isLiveRuntimeMethod(envelope.Method) { + t.Fatalf("expected streamed runtime notifications to be skipped after response, got %+v", envelope) + } + } + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear replay deadline: %v", err) + } +} + +func TestHandleStreamConnFiltersRuntimeNotificationsToRequestTask(t *testing.T) { + modelClient := &stubLoopModelClient{ + toolResult: model.ToolCallResult{ + OutputText: "Scoped runtime finished.", + }, + generateToolWait: make(chan struct{}), + } + server := newTestServerWithModelClient(modelClient) + + startTask := func(sessionID string) string { + t.Helper() + result, err := startTaskForTest(server.orchestrator, map[string]any{ + "session_id": sessionID, + "source": "floating_ball", + "trigger": "text_selected_click", + "input": map[string]any{ + "type": "text_selection", + "text": "inspect this workspace", + }, + }) + if err != nil { + t.Fatalf("seed task.start for %s: %v", sessionID, err) + } + return result["task"].(map[string]any)["task_id"].(string) + } + + taskA := startTask("sess_loop_scope_a") + taskB := startTask("sess_loop_scope_b") + + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-loop-scope"`), + Method: "agent.task.confirm", + Params: mustMarshal(t, map[string]any{ + "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{}, + }, + }), + } + + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode request: %v", err) + } + if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("set first notification deadline: %v", err) + } + + var firstEnvelope notificationEnvelope + if err := decoder.Decode(&firstEnvelope); err != nil { + t.Fatalf("decode first notification: %v", err) + } + if !strings.HasPrefix(firstEnvelope.Method, "loop.") { + t.Fatalf("expected first streamed envelope to be loop.* notification, got %+v", firstEnvelope) + } + + confirmDone := make(chan error, 1) + go func() { + _, err := server.orchestrator.ConfirmTask(map[string]any{ + "task_id": taskB, + "confirmed": false, + "corrected_intent": map[string]any{ + "name": "agent_loop", + "arguments": map[string]any{}, + }, + }) + confirmDone <- err + }() + + if err := right.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil { + t.Fatalf("set scoped notification deadline: %v", err) + } + for { + var envelope notificationEnvelope + if err := decoder.Decode(&envelope); err != nil { + break + } + params, ok := envelope.Params.(map[string]any) + if !ok { + t.Fatalf("expected notification params map, got %+v", envelope) + } + taskID := stringValue(params, "task_id", "") + if taskID == taskB { + t.Fatalf("expected stream to suppress unrelated runtime notification for task %s, got %+v", taskB, envelope) + } + } + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear read deadline: %v", err) + } + + close(modelClient.generateToolWait) + + select { + case err := <-confirmDone: + if err != nil { + t.Fatalf("confirm unrelated task: %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("expected unrelated task confirmation to complete") + } +} + +func TestHandleStreamConnAllowsSettingsReadWhileTaskConfirmWaits(t *testing.T) { + server := newTestServer() + blockingSeen := make(chan struct{}) + releaseBlocking := make(chan struct{}) + releasedBlocking := false + defer func() { + if !releasedBlocking { + close(releaseBlocking) + } + }() + + server.handlers["test.blocking"] = func(_ map[string]any) (any, *rpcError) { + select { + case <-blockingSeen: + default: + close(blockingSeen) + } + <-releaseBlocking + return map[string]any{"status": "released"}, nil + } + server.handlers["test.fast"] = func(_ map[string]any) (any, *rpcError) { + return map[string]any{"status": "fast"}, nil + } + + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + go server.handleStreamConn(left) + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + blockingRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-blocking"`), + Method: "test.blocking", + Params: mustMarshal(t, map[string]any{}), + } + if err := encoder.Encode(blockingRequest); err != nil { + t.Fatalf("encode blocked request: %v", err) + } + + select { + case <-blockingSeen: + case <-time.After(500 * time.Millisecond): + t.Fatal("expected blocking request to start running") + } + + settingsRequest := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-fast"`), + Method: "test.fast", + Params: mustMarshal(t, map[string]any{}), + } + if err := encoder.Encode(settingsRequest); err != nil { + t.Fatalf("encode fast request: %v", err) + } + + if err := right.SetReadDeadline(time.Now().Add(1 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + + seenSettingsResponse := false + for !seenSettingsResponse { + var envelope map[string]any + if err := decoder.Decode(&envelope); err != nil { + t.Fatalf("decode concurrent envelope: %v", err) + } + + id, _ := envelope["id"].(string) + if id != "req-fast" { + continue + } + + result, ok := envelope["result"].(map[string]any) + if !ok { + t.Fatalf("expected fast response result envelope, got %+v", envelope) + } + data, ok := result["data"].(map[string]any) + if !ok { + t.Fatalf("expected fast response data payload, got %+v", result) + } + if stringValue(data, "status", "") != "fast" { + t.Fatalf("expected fast request result payload, got %+v", data) + } + seenSettingsResponse = true + } + + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear read deadline: %v", err) + } + + close(releaseBlocking) + releasedBlocking = true +} diff --git a/services/local-service/internal/rpc/server_stream_queue_test.go b/services/local-service/internal/rpc/server_stream_queue_test.go new file mode 100644 index 000000000..b1b6e9374 --- /dev/null +++ b/services/local-service/internal/rpc/server_stream_queue_test.go @@ -0,0 +1,494 @@ +// RPC stream queue tests verify pending-request backpressure and disconnect handling. +package rpc + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "sync" + "testing" + "time" +) + +func TestHandleStreamConnAppliesBackpressureWhenPendingQueueFills(t *testing.T) { + server := newTestServer() + startedSignals := make(chan struct{}, maxPendingStreamRequests+1) + releaseBlocking := make(chan struct{}) + releasedBlocking := false + defer func() { + if !releasedBlocking { + close(releaseBlocking) + } + }() + + var startedMu sync.Mutex + startedCount := 0 + server.handlers["test.blocking"] = func(_ map[string]any) (any, *rpcError) { + startedMu.Lock() + startedCount++ + startedMu.Unlock() + + startedSignals <- struct{}{} + <-releaseBlocking + return map[string]any{"status": "released"}, nil + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen loopback: %v", err) + } + defer listener.Close() + + acceptDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + acceptDone <- err + return + } + server.handleStreamConn(conn) + acceptDone <- nil + }() + + right, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("dial loopback: %v", err) + } + defer func() { + _ = right.Close() + select { + case err := <-acceptDone: + if err != nil && !errors.Is(err, net.ErrClosed) { + t.Fatalf("accept loopback: %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("expected loopback stream to shut down") + } + }() + + encoder := json.NewEncoder(right) + for index := 0; index < maxPendingStreamRequests; index++ { + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(fmt.Sprintf(`"req-blocking-%d"`, index)), + Method: "test.blocking", + Params: mustMarshal(t, map[string]any{}), + } + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode blocking request %d: %v", index, err) + } + } + + for index := 0; index < maxPendingStreamRequests; index++ { + select { + case <-startedSignals: + case <-time.After(2 * time.Second): + t.Fatalf("expected request %d to start before the queue filled", index) + } + } + + startedMu.Lock() + if startedCount != maxPendingStreamRequests { + startedMu.Unlock() + t.Fatalf("expected exactly %d started requests before backpressure, got %d", maxPendingStreamRequests, startedCount) + } + startedMu.Unlock() + + extraRequestDone := make(chan error, 1) + go func() { + extraRequestDone <- encoder.Encode(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-blocking-overflow"`), + Method: "test.blocking", + Params: mustMarshal(t, map[string]any{}), + }) + }() + + select { + case <-startedSignals: + t.Fatal("expected overflow request to wait until a pending slot is released") + case <-time.After(250 * time.Millisecond): + } + + close(releaseBlocking) + releasedBlocking = true + + select { + case <-startedSignals: + case <-time.After(2 * time.Second): + t.Fatal("expected overflow request to start after pending capacity became available") + } + + select { + case err := <-extraRequestDone: + if err != nil { + t.Fatalf("encode overflow request: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("expected overflow request write to complete after backpressure released") + } +} + +func TestHandleStreamConnDropsOverflowRequestAfterDisconnectWithFullPendingQueue(t *testing.T) { + server := newTestServer() + startedSignals := make(chan struct{}, maxPendingStreamRequests+1) + releaseBlocking := make(chan struct{}) + releasedBlocking := false + defer func() { + if !releasedBlocking { + close(releaseBlocking) + } + }() + + var startedMu sync.Mutex + startedCount := 0 + server.handlers["test.blocking"] = func(_ map[string]any) (any, *rpcError) { + startedMu.Lock() + startedCount++ + startedMu.Unlock() + + startedSignals <- struct{}{} + <-releaseBlocking + return map[string]any{"status": "released"}, nil + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen loopback: %v", err) + } + defer listener.Close() + + acceptDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + acceptDone <- err + return + } + server.handleStreamConn(conn) + acceptDone <- nil + }() + + right, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("dial loopback: %v", err) + } + + encoder := json.NewEncoder(right) + for index := 0; index < maxPendingStreamRequests; index++ { + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(fmt.Sprintf(`"req-disconnect-blocking-%d"`, index)), + Method: "test.blocking", + Params: mustMarshal(t, map[string]any{}), + } + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode blocking request %d: %v", index, err) + } + } + + for index := 0; index < maxPendingStreamRequests; index++ { + select { + case <-startedSignals: + case <-time.After(2 * time.Second): + t.Fatalf("expected request %d to start before the queue filled", index) + } + } + + startedMu.Lock() + if startedCount != maxPendingStreamRequests { + startedMu.Unlock() + t.Fatalf("expected exactly %d started requests before the disconnect race, got %d", maxPendingStreamRequests, startedCount) + } + startedMu.Unlock() + + extraRequestDone := make(chan error, 1) + go func() { + extraRequestDone <- encoder.Encode(requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(`"req-disconnect-overflow"`), + Method: "test.blocking", + Params: mustMarshal(t, map[string]any{}), + }) + }() + + select { + case <-startedSignals: + t.Fatal("expected overflow request to remain queued before disconnect") + case <-time.After(250 * time.Millisecond): + } + + if err := right.Close(); err != nil { + t.Fatalf("close client stream: %v", err) + } + + select { + case <-extraRequestDone: + case <-time.After(2 * time.Second): + t.Fatal("expected overflow request write to exit after client disconnect") + } + + close(releaseBlocking) + releasedBlocking = true + + select { + case <-startedSignals: + t.Fatal("expected disconnected overflow request not to start after pending capacity frees") + case <-time.After(300 * time.Millisecond): + } + + startedMu.Lock() + if startedCount != maxPendingStreamRequests { + startedMu.Unlock() + t.Fatalf("expected disconnect to prevent stale overflow dispatch, got %d calls", startedCount) + } + startedMu.Unlock() + + select { + case err := <-acceptDone: + if err != nil && !errors.Is(err, net.ErrClosed) { + t.Fatalf("accept loopback: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("expected loopback stream to shut down after pending workers released") + } +} + +func TestHandleStreamConnDropsDecodedSameTaskBacklogAfterDisconnect(t *testing.T) { + server := newTestServer() + taskID := "task_disconnect_same_task_backlog" + startedSignals := make(chan int, maxPendingStreamRequests) + releaseFirst := make(chan struct{}) + + var startedMu sync.Mutex + startedCount := 0 + server.handlers["test.same.task.blocking"] = func(params map[string]any) (any, *rpcError) { + startedMu.Lock() + startedCount++ + callIndex := startedCount + startedMu.Unlock() + + startedSignals <- callIndex + if callIndex == 1 { + <-releaseFirst + } + + return map[string]any{ + "task": map[string]any{ + "task_id": stringValue(params, "task_id", ""), + }, + }, nil + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen loopback: %v", err) + } + defer listener.Close() + + acceptDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + acceptDone <- err + return + } + server.handleStreamConn(conn) + acceptDone <- nil + }() + + right, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("dial loopback: %v", err) + } + + encoder := json.NewEncoder(right) + for index := 0; index < maxPendingStreamRequests; index++ { + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(fmt.Sprintf(`"req-same-task-disconnect-%d"`, index)), + Method: "test.same.task.blocking", + Params: mustMarshal(t, map[string]any{ + "task_id": taskID, + }), + } + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode same-task request %d: %v", index, err) + } + } + + select { + case callIndex := <-startedSignals: + if callIndex != 1 { + t.Fatalf("expected the first same-task request to start first, got call %d", callIndex) + } + case <-time.After(2 * time.Second): + t.Fatal("expected the first same-task request to start") + } + + select { + case callIndex := <-startedSignals: + t.Fatalf("expected same-task backlog to stay queued behind the first request, got call %d", callIndex) + case <-time.After(250 * time.Millisecond): + } + + if err := right.Close(); err != nil { + t.Fatalf("close client stream: %v", err) + } + + close(releaseFirst) + + select { + case callIndex := <-startedSignals: + t.Fatalf("expected disconnected same-task backlog not to start after release, got call %d", callIndex) + case <-time.After(500 * time.Millisecond): + } + + startedMu.Lock() + if startedCount != 1 { + startedMu.Unlock() + t.Fatalf("expected only the first same-task request to dispatch before disconnect, got %d", startedCount) + } + startedMu.Unlock() + + select { + case err := <-acceptDone: + if err != nil && !errors.Is(err, net.ErrClosed) { + t.Fatalf("accept loopback: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("expected loopback stream to shut down after same-task backlog release") + } +} + +func TestHandleStreamConnKeepsHealthyIdleSameTaskBacklogAlive(t *testing.T) { + server := newTestServer() + taskID := "task_idle_same_task_backlog" + startedSignals := make(chan int, maxPendingStreamRequests) + releaseFirst := make(chan struct{}) + + var startedMu sync.Mutex + startedCount := 0 + server.handlers["test.same.task.healthy"] = func(params map[string]any) (any, *rpcError) { + startedMu.Lock() + startedCount++ + callIndex := startedCount + startedMu.Unlock() + + startedSignals <- callIndex + if callIndex == 1 { + <-releaseFirst + } + + return map[string]any{ + "task": map[string]any{ + "task_id": stringValue(params, "task_id", ""), + }, + }, nil + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen loopback: %v", err) + } + defer listener.Close() + + acceptDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + acceptDone <- err + return + } + server.handleStreamConn(conn) + acceptDone <- nil + }() + + right, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("dial loopback: %v", err) + } + defer right.Close() + + encoder := json.NewEncoder(right) + decoder := json.NewDecoder(right) + for index := 0; index < maxPendingStreamRequests; index++ { + request := requestEnvelope{ + JSONRPC: "2.0", + ID: json.RawMessage(fmt.Sprintf(`"req-same-task-healthy-%d"`, index)), + Method: "test.same.task.healthy", + Params: mustMarshal(t, map[string]any{ + "task_id": taskID, + }), + } + if err := encoder.Encode(request); err != nil { + t.Fatalf("encode same-task request %d: %v", index, err) + } + } + + select { + case callIndex := <-startedSignals: + if callIndex != 1 { + t.Fatalf("expected the first same-task request to start first, got call %d", callIndex) + } + case <-time.After(2 * time.Second): + t.Fatal("expected the first same-task request to start") + } + + select { + case callIndex := <-startedSignals: + t.Fatalf("expected same-task backlog to stay queued behind the first request, got call %d", callIndex) + case <-time.After(250 * time.Millisecond): + } + + close(releaseFirst) + + if err := right.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + defer func() { + if err := right.SetReadDeadline(time.Time{}); err != nil { + t.Fatalf("clear read deadline: %v", err) + } + }() + + var firstEnvelope map[string]any + if err := decoder.Decode(&firstEnvelope); err != nil { + t.Fatalf("decode first same-task response: %v", err) + } + if firstEnvelope["id"] == nil { + t.Fatalf("expected the first same-task response envelope, got %+v", firstEnvelope) + } + if firstEnvelope["error"] != nil { + t.Fatalf("expected first same-task response to succeed, got %+v", firstEnvelope) + } + + select { + case callIndex := <-startedSignals: + if callIndex != 2 { + t.Fatalf("expected the second same-task request to dispatch next, got call %d", callIndex) + } + case <-time.After(2 * time.Second): + t.Fatal("expected the second same-task request to dispatch after the first response") + } + + var secondEnvelope map[string]any + if err := decoder.Decode(&secondEnvelope); err != nil { + t.Fatalf("decode second same-task response: %v", err) + } + if secondEnvelope["id"] == nil { + t.Fatalf("expected the second same-task response envelope, got %+v", secondEnvelope) + } + if secondEnvelope["error"] != nil { + t.Fatalf("expected same-task backlog to stay on the healthy shared stream, got %+v", secondEnvelope) + } + + select { + case err := <-acceptDone: + if err != nil && !errors.Is(err, net.ErrClosed) { + t.Fatalf("accept loopback: %v", err) + } + case <-time.After(250 * time.Millisecond): + } +} diff --git a/services/local-service/internal/rpc/server_test.go b/services/local-service/internal/rpc/server_test.go deleted file mode 100644 index 8b1605c6c..000000000 --- a/services/local-service/internal/rpc/server_test.go +++ /dev/null @@ -1,3557 +0,0 @@ -// RPC server tests verify response envelopes and notification behavior. -package rpc - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net" - "net/http/httptest" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/cialloclaw/cialloclaw/services/local-service/internal/audit" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/checkpoint" - serviceconfig "github.com/cialloclaw/cialloclaw/services/local-service/internal/config" - contextsvc "github.com/cialloclaw/cialloclaw/services/local-service/internal/context" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/delivery" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/execution" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/intent" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/memory" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/model" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/platform" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/plugin" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/risk" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/runengine" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/storage" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/taskinspector" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/tools" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/tools/builtin" - "github.com/cialloclaw/cialloclaw/services/local-service/internal/tools/sidecarclient" -) - -type stubLoopModelClient struct { - toolResult model.ToolCallResult - generateToolWait chan struct{} - generateToolSeen chan struct{} -} - -// selectiveWaitLoopModelClient only applies the blocking tool-call gate to one -// task so stream-serialization tests can distinguish per-task locking from -// unrelated concurrent requests. -type selectiveWaitLoopModelClient struct { - stubLoopModelClient - blockedTaskID string -} - -func (s *stubLoopModelClient) GenerateText(_ context.Context, request model.GenerateTextRequest) (model.GenerateTextResponse, error) { - return model.GenerateTextResponse{ - TaskID: request.TaskID, - RunID: request.RunID, - RequestID: "req_loop_text", - Provider: "openai_responses", - ModelID: "gpt-5.4", - OutputText: "loop fallback output", - }, nil -} - -func (s *stubLoopModelClient) GenerateToolCalls(_ context.Context, request model.ToolCallRequest) (model.ToolCallResult, error) { - if s.generateToolSeen != nil { - select { - case <-s.generateToolSeen: - default: - close(s.generateToolSeen) - } - } - if s.generateToolWait != nil { - <-s.generateToolWait - } - result := s.toolResult - if strings.TrimSpace(result.OutputText) == "" && len(result.ToolCalls) == 0 { - result.OutputText = request.Input - } - if result.RequestID == "" { - result.RequestID = "req_loop_tools" - } - if result.Provider == "" { - result.Provider = "openai_responses" - } - if result.ModelID == "" { - result.ModelID = "gpt-5.4" - } - return result, nil -} - -func (s *selectiveWaitLoopModelClient) GenerateToolCalls(ctx context.Context, request model.ToolCallRequest) (model.ToolCallResult, error) { - if strings.TrimSpace(s.blockedTaskID) == "" || request.TaskID == s.blockedTaskID { - return s.stubLoopModelClient.GenerateToolCalls(ctx, request) - } - - result := s.toolResult - if strings.TrimSpace(result.OutputText) == "" && len(result.ToolCalls) == 0 { - result.OutputText = request.Input - } - if result.RequestID == "" { - result.RequestID = "req_loop_tools" - } - if result.Provider == "" { - result.Provider = "openai_responses" - } - if result.ModelID == "" { - result.ModelID = "gpt-5.4" - } - return result, nil -} - -type testStorageAdapter struct { - databasePath string -} - -type stubExecutionCapability struct { - result tools.CommandExecutionResult - err error -} - -func rpcRequestMeta(traceID string) map[string]any { - return map[string]any{ - "trace_id": traceID, - "client_time": "2026-05-10T00:00:00Z", - } -} - -func (s stubExecutionCapability) RunCommand(_ context.Context, _ string, _ []string, _ string) (tools.CommandExecutionResult, error) { - if s.err != nil { - return tools.CommandExecutionResult{}, s.err - } - return s.result, nil -} - -func (a testStorageAdapter) DatabasePath() string { - return a.databasePath -} - -func (a testStorageAdapter) SecretStorePath() string { - if a.databasePath == "" { - return "" - } - return a.databasePath + ".stronghold" -} - -// TestHandleStreamConnEmitsApprovalNotifications verifies that approval notifications -// are emitted on the stream connection after task confirmation enters waiting_auth. -func TestHandleStreamConnEmitsApprovalNotifications(t *testing.T) { - server := newTestServer() - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "请生成一个文件版本", - }, - }) - if err != nil { - t.Fatalf("seed task.start: %v", err) - } - taskID := startResult["task"].(map[string]any)["task_id"].(string) - if startResult["task"].(map[string]any)["status"] != "confirming_intent" { - t.Fatalf("expected seeded task to wait for confirm, got %+v", startResult["task"]) - } - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-1"`), - Method: "agent.task.confirm", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_task_confirm_approval"), - "task_id": taskID, - "confirmed": false, - "corrected_intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - "target_path": "workspace_document", - }, - }, - }), - } - - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode request: %v", err) - } - - var response successEnvelope - 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" { - t.Fatalf("expected waiting_auth task status in response") - } - - if err := right.SetReadDeadline(time.Now().Add(200 * time.Millisecond)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - - seenApprovalPending := false - for index := 0; index < 8; index++ { - var notification notificationEnvelope - if err := decoder.Decode(¬ification); err != nil { - break - } - if notification.Method == "approval.pending" { - seenApprovalPending = true - } - } - - if !seenApprovalPending { - t.Fatal("expected approval.pending notification to be emitted on stream connection") - } -} - -func TestHandleStreamConnEmitsLoopLifecycleNotifications(t *testing.T) { - server := newTestServer() - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_loop_notify", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "Inspect the workspace and answer.", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, - }, - }) - if err != nil { - t.Fatalf("seed task.start: %v", err) - } - taskID := startResult["task"].(map[string]any)["task_id"].(string) - if _, ok := server.orchestrator.RunEngine().EmitRuntimeNotification(taskID, "loop.round.completed", map[string]any{ - "loop_round": 1, - "stop_reason": "completed", - }); !ok { - t.Fatal("expected runtime notification injection to succeed") - } - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-detail"`), - Method: "agent.task.detail.get", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_task_detail_loop_notify", - "client_time": "2026-05-10T10:00:00Z", - }, - "task_id": taskID, - }), - } - - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode request: %v", err) - } - - var response successEnvelope - 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 { - t.Fatalf("expected task detail response for %s, got %+v", taskID, response) - } - - if err := right.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - seenLoopNotification := false - for index := 0; index < 12; index++ { - var notification notificationEnvelope - if err := decoder.Decode(¬ification); err != nil { - break - } - if strings.HasPrefix(notification.Method, "loop.") { - seenLoopNotification = true - break - } - } - if !seenLoopNotification { - t.Fatal("expected loop.* notification to be emitted on stream connection") - } -} - -func TestHandleStreamConnStreamsLoopLifecycleNotificationsBeforeResponse(t *testing.T) { - modelClient := &stubLoopModelClient{ - toolResult: model.ToolCallResult{ - OutputText: "Loop runtime finished in-flight.", - }, - generateToolWait: make(chan struct{}), - generateToolSeen: make(chan struct{}), - } - server := newTestServerWithModelClient(modelClient) - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_loop_stream", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "inspect this workspace", - }, - }) - if err != nil { - t.Fatalf("seed task.start: %v", err) - } - taskID := startResult["task"].(map[string]any)["task_id"].(string) - if startResult["task"].(map[string]any)["status"] != "confirming_intent" { - t.Fatalf("expected seeded task to wait for confirm, got %+v", startResult["task"]) - } - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-loop-stream"`), - Method: "agent.task.confirm", - Params: mustMarshal(t, map[string]any{ - "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{}, - }, - }), - } - - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode request: %v", err) - } - if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - - var firstEnvelope map[string]any - if err := decoder.Decode(&firstEnvelope); err != nil { - t.Fatalf("decode first envelope: %v", err) - } - if method, _ := firstEnvelope["method"].(string); !strings.HasPrefix(method, "loop.") { - t.Fatalf("expected first streamed envelope to be loop.* notification, got %+v", firstEnvelope) - } - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear read deadline: %v", err) - } - - close(modelClient.generateToolWait) - - if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { - t.Fatalf("set response deadline: %v", err) - } - responseSeen := false - for index := 0; index < 8; index++ { - var envelope map[string]any - if err := decoder.Decode(&envelope); err != nil { - t.Fatalf("decode response envelope: %v", err) - } - if envelope["id"] == nil { - continue - } - result, ok := envelope["result"].(map[string]any) - if !ok { - t.Fatalf("expected success result envelope, got %+v", envelope) - } - data, ok := result["data"].(map[string]any) - if !ok { - t.Fatalf("expected response data payload, got %+v", envelope) - } - task, ok := data["task"].(map[string]any) - if !ok || task["status"] != "completed" { - t.Fatalf("expected completed task response, got %+v", envelope) - } - responseSeen = true - break - } - if !responseSeen { - t.Fatal("expected final response after streamed loop notifications") - } -} - -func TestHandleStreamConnStreamsLoopLifecycleNotificationsBeforeResponseForSubmitInput(t *testing.T) { - modelClient := &stubLoopModelClient{ - toolResult: model.ToolCallResult{ - OutputText: "Loop runtime finished from input.submit.", - }, - generateToolWait: make(chan struct{}), - generateToolSeen: make(chan struct{}), - } - server := newTestServerWithModelClient(modelClient) - - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-input-submit-loop-stream"`), - Method: "agent.input.submit", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_input_submit_loop_stream", - "client_time": "2026-05-10T10:00:00Z", - }, - "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", - "input_mode": "text", - }, - "context": map[string]any{}, - "options": map[string]any{ - "confirm_required": false, - }, - }), - } - - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode request: %v", err) - } - if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - - var firstEnvelope map[string]any - if err := decoder.Decode(&firstEnvelope); err != nil { - t.Fatalf("decode first envelope: %v", err) - } - if method, _ := firstEnvelope["method"].(string); !strings.HasPrefix(method, "loop.") { - t.Fatalf("expected first streamed envelope to be loop.* notification, got %+v", firstEnvelope) - } - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear read deadline: %v", err) - } - - close(modelClient.generateToolWait) - - if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { - t.Fatalf("set response deadline: %v", err) - } - responseSeen := false - for index := 0; index < 8; index++ { - var envelope map[string]any - if err := decoder.Decode(&envelope); err != nil { - t.Fatalf("decode response envelope: %v", err) - } - if envelope["id"] == nil { - continue - } - result, ok := envelope["result"].(map[string]any) - if !ok { - t.Fatalf("expected success result envelope, got %+v", envelope) - } - data, ok := result["data"].(map[string]any) - if !ok { - t.Fatalf("expected response data payload, got %+v", envelope) - } - task, ok := data["task"].(map[string]any) - if !ok || task["status"] != "completed" { - t.Fatalf("expected completed task response, got %+v", envelope) - } - responseSeen = true - break - } - if !responseSeen { - t.Fatal("expected final response after streamed loop notifications") - } -} - -func TestHandleStreamConnDoesNotReplayStreamedRuntimeNotificationsAfterResponse(t *testing.T) { - modelClient := &stubLoopModelClient{ - toolResult: model.ToolCallResult{ - OutputText: "Loop runtime should not replay live events.", - }, - generateToolWait: make(chan struct{}), - generateToolSeen: make(chan struct{}), - } - server := newTestServerWithModelClient(modelClient) - - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-loop-no-replay"`), - Method: "agent.input.submit", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_input_submit_no_replay", - "client_time": "2026-05-10T10:00:00Z", - }, - "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", - "input_mode": "text", - }, - "context": map[string]any{}, - "options": map[string]any{ - "confirm_required": false, - }, - }), - } - - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode request: %v", err) - } - if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { - t.Fatalf("set first notification deadline: %v", err) - } - - var firstEnvelope notificationEnvelope - if err := decoder.Decode(&firstEnvelope); err != nil { - t.Fatalf("decode first notification: %v", err) - } - if !strings.HasPrefix(firstEnvelope.Method, "loop.") { - t.Fatalf("expected first streamed envelope to be loop.* notification, got %+v", firstEnvelope) - } - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear read deadline: %v", err) - } - - close(modelClient.generateToolWait) - - if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { - t.Fatalf("set response deadline: %v", err) - } - responseSeen := false - for index := 0; index < 8; index++ { - var envelope map[string]any - if err := decoder.Decode(&envelope); err != nil { - t.Fatalf("decode response envelope: %v", err) - } - if envelope["id"] == nil { - continue - } - responseSeen = true - break - } - if !responseSeen { - t.Fatal("expected final response after streamed loop notifications") - } - - if err := right.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil { - t.Fatalf("set replay deadline: %v", err) - } - for { - var envelope notificationEnvelope - if err := decoder.Decode(&envelope); err != nil { - break - } - if isLiveRuntimeMethod(envelope.Method) { - t.Fatalf("expected streamed runtime notifications to be skipped after response, got %+v", envelope) - } - } - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear replay deadline: %v", err) - } -} - -func TestHandleStreamConnFiltersRuntimeNotificationsToRequestTask(t *testing.T) { - modelClient := &stubLoopModelClient{ - toolResult: model.ToolCallResult{ - OutputText: "Scoped runtime finished.", - }, - generateToolWait: make(chan struct{}), - } - server := newTestServerWithModelClient(modelClient) - - startTask := func(sessionID string) string { - t.Helper() - result, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": sessionID, - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "inspect this workspace", - }, - }) - if err != nil { - t.Fatalf("seed task.start for %s: %v", sessionID, err) - } - return result["task"].(map[string]any)["task_id"].(string) - } - - taskA := startTask("sess_loop_scope_a") - taskB := startTask("sess_loop_scope_b") - - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-loop-scope"`), - Method: "agent.task.confirm", - Params: mustMarshal(t, map[string]any{ - "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{}, - }, - }), - } - - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode request: %v", err) - } - if err := right.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { - t.Fatalf("set first notification deadline: %v", err) - } - - var firstEnvelope notificationEnvelope - if err := decoder.Decode(&firstEnvelope); err != nil { - t.Fatalf("decode first notification: %v", err) - } - if !strings.HasPrefix(firstEnvelope.Method, "loop.") { - t.Fatalf("expected first streamed envelope to be loop.* notification, got %+v", firstEnvelope) - } - - confirmDone := make(chan error, 1) - go func() { - _, err := server.orchestrator.ConfirmTask(map[string]any{ - "task_id": taskB, - "confirmed": false, - "corrected_intent": map[string]any{ - "name": "agent_loop", - "arguments": map[string]any{}, - }, - }) - confirmDone <- err - }() - - if err := right.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil { - t.Fatalf("set scoped notification deadline: %v", err) - } - for { - var envelope notificationEnvelope - if err := decoder.Decode(&envelope); err != nil { - break - } - params, ok := envelope.Params.(map[string]any) - if !ok { - t.Fatalf("expected notification params map, got %+v", envelope) - } - taskID := stringValue(params, "task_id", "") - if taskID == taskB { - t.Fatalf("expected stream to suppress unrelated runtime notification for task %s, got %+v", taskB, envelope) - } - } - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear read deadline: %v", err) - } - - close(modelClient.generateToolWait) - - select { - case err := <-confirmDone: - if err != nil { - t.Fatalf("confirm unrelated task: %v", err) - } - case <-time.After(500 * time.Millisecond): - t.Fatal("expected unrelated task confirmation to complete") - } -} - -func TestHandleStreamConnAllowsSettingsReadWhileTaskConfirmWaits(t *testing.T) { - server := newTestServer() - blockingSeen := make(chan struct{}) - releaseBlocking := make(chan struct{}) - releasedBlocking := false - defer func() { - if !releasedBlocking { - close(releaseBlocking) - } - }() - - server.handlers["test.blocking"] = func(_ map[string]any) (any, *rpcError) { - select { - case <-blockingSeen: - default: - close(blockingSeen) - } - <-releaseBlocking - return map[string]any{"status": "released"}, nil - } - server.handlers["test.fast"] = func(_ map[string]any) (any, *rpcError) { - return map[string]any{"status": "fast"}, nil - } - - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - blockingRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-blocking"`), - Method: "test.blocking", - Params: mustMarshal(t, map[string]any{}), - } - if err := encoder.Encode(blockingRequest); err != nil { - t.Fatalf("encode blocked request: %v", err) - } - - select { - case <-blockingSeen: - case <-time.After(500 * time.Millisecond): - t.Fatal("expected blocking request to start running") - } - - settingsRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-fast"`), - Method: "test.fast", - Params: mustMarshal(t, map[string]any{}), - } - if err := encoder.Encode(settingsRequest); err != nil { - t.Fatalf("encode fast request: %v", err) - } - - if err := right.SetReadDeadline(time.Now().Add(1 * time.Second)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - - seenSettingsResponse := false - for !seenSettingsResponse { - var envelope map[string]any - if err := decoder.Decode(&envelope); err != nil { - t.Fatalf("decode concurrent envelope: %v", err) - } - - id, _ := envelope["id"].(string) - if id != "req-fast" { - continue - } - - result, ok := envelope["result"].(map[string]any) - if !ok { - t.Fatalf("expected fast response result envelope, got %+v", envelope) - } - data, ok := result["data"].(map[string]any) - if !ok { - t.Fatalf("expected fast response data payload, got %+v", result) - } - if stringValue(data, "status", "") != "fast" { - t.Fatalf("expected fast request result payload, got %+v", data) - } - seenSettingsResponse = true - } - - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear read deadline: %v", err) - } - - close(releaseBlocking) - releasedBlocking = true -} - -func TestHandleStreamConnAppliesBackpressureWhenPendingQueueFills(t *testing.T) { - server := newTestServer() - startedSignals := make(chan struct{}, maxPendingStreamRequests+1) - releaseBlocking := make(chan struct{}) - releasedBlocking := false - defer func() { - if !releasedBlocking { - close(releaseBlocking) - } - }() - - var startedMu sync.Mutex - startedCount := 0 - server.handlers["test.blocking"] = func(_ map[string]any) (any, *rpcError) { - startedMu.Lock() - startedCount++ - startedMu.Unlock() - - startedSignals <- struct{}{} - <-releaseBlocking - return map[string]any{"status": "released"}, nil - } - - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen loopback: %v", err) - } - defer listener.Close() - - acceptDone := make(chan error, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - acceptDone <- err - return - } - server.handleStreamConn(conn) - acceptDone <- nil - }() - - right, err := net.Dial("tcp", listener.Addr().String()) - if err != nil { - t.Fatalf("dial loopback: %v", err) - } - defer func() { - _ = right.Close() - select { - case err := <-acceptDone: - if err != nil && !errors.Is(err, net.ErrClosed) { - t.Fatalf("accept loopback: %v", err) - } - case <-time.After(500 * time.Millisecond): - t.Fatal("expected loopback stream to shut down") - } - }() - - encoder := json.NewEncoder(right) - for index := 0; index < maxPendingStreamRequests; index++ { - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(fmt.Sprintf(`"req-blocking-%d"`, index)), - Method: "test.blocking", - Params: mustMarshal(t, map[string]any{}), - } - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode blocking request %d: %v", index, err) - } - } - - for index := 0; index < maxPendingStreamRequests; index++ { - select { - case <-startedSignals: - case <-time.After(2 * time.Second): - t.Fatalf("expected request %d to start before the queue filled", index) - } - } - - startedMu.Lock() - if startedCount != maxPendingStreamRequests { - startedMu.Unlock() - t.Fatalf("expected exactly %d started requests before backpressure, got %d", maxPendingStreamRequests, startedCount) - } - startedMu.Unlock() - - extraRequestDone := make(chan error, 1) - go func() { - extraRequestDone <- encoder.Encode(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-blocking-overflow"`), - Method: "test.blocking", - Params: mustMarshal(t, map[string]any{}), - }) - }() - - select { - case <-startedSignals: - t.Fatal("expected overflow request to wait until a pending slot is released") - case <-time.After(250 * time.Millisecond): - } - - close(releaseBlocking) - releasedBlocking = true - - select { - case <-startedSignals: - case <-time.After(2 * time.Second): - t.Fatal("expected overflow request to start after pending capacity became available") - } - - select { - case err := <-extraRequestDone: - if err != nil { - t.Fatalf("encode overflow request: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("expected overflow request write to complete after backpressure released") - } -} - -func TestHandleStreamConnDropsOverflowRequestAfterDisconnectWithFullPendingQueue(t *testing.T) { - server := newTestServer() - startedSignals := make(chan struct{}, maxPendingStreamRequests+1) - releaseBlocking := make(chan struct{}) - releasedBlocking := false - defer func() { - if !releasedBlocking { - close(releaseBlocking) - } - }() - - var startedMu sync.Mutex - startedCount := 0 - server.handlers["test.blocking"] = func(_ map[string]any) (any, *rpcError) { - startedMu.Lock() - startedCount++ - startedMu.Unlock() - - startedSignals <- struct{}{} - <-releaseBlocking - return map[string]any{"status": "released"}, nil - } - - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen loopback: %v", err) - } - defer listener.Close() - - acceptDone := make(chan error, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - acceptDone <- err - return - } - server.handleStreamConn(conn) - acceptDone <- nil - }() - - right, err := net.Dial("tcp", listener.Addr().String()) - if err != nil { - t.Fatalf("dial loopback: %v", err) - } - - encoder := json.NewEncoder(right) - for index := 0; index < maxPendingStreamRequests; index++ { - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(fmt.Sprintf(`"req-disconnect-blocking-%d"`, index)), - Method: "test.blocking", - Params: mustMarshal(t, map[string]any{}), - } - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode blocking request %d: %v", index, err) - } - } - - for index := 0; index < maxPendingStreamRequests; index++ { - select { - case <-startedSignals: - case <-time.After(2 * time.Second): - t.Fatalf("expected request %d to start before the queue filled", index) - } - } - - startedMu.Lock() - if startedCount != maxPendingStreamRequests { - startedMu.Unlock() - t.Fatalf("expected exactly %d started requests before the disconnect race, got %d", maxPendingStreamRequests, startedCount) - } - startedMu.Unlock() - - extraRequestDone := make(chan error, 1) - go func() { - extraRequestDone <- encoder.Encode(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-disconnect-overflow"`), - Method: "test.blocking", - Params: mustMarshal(t, map[string]any{}), - }) - }() - - select { - case <-startedSignals: - t.Fatal("expected overflow request to remain queued before disconnect") - case <-time.After(250 * time.Millisecond): - } - - if err := right.Close(); err != nil { - t.Fatalf("close client stream: %v", err) - } - - select { - case <-extraRequestDone: - case <-time.After(2 * time.Second): - t.Fatal("expected overflow request write to exit after client disconnect") - } - - close(releaseBlocking) - releasedBlocking = true - - select { - case <-startedSignals: - t.Fatal("expected disconnected overflow request not to start after pending capacity frees") - case <-time.After(300 * time.Millisecond): - } - - startedMu.Lock() - if startedCount != maxPendingStreamRequests { - startedMu.Unlock() - t.Fatalf("expected disconnect to prevent stale overflow dispatch, got %d calls", startedCount) - } - startedMu.Unlock() - - select { - case err := <-acceptDone: - if err != nil && !errors.Is(err, net.ErrClosed) { - t.Fatalf("accept loopback: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("expected loopback stream to shut down after pending workers released") - } -} - -func TestHandleStreamConnDropsDecodedSameTaskBacklogAfterDisconnect(t *testing.T) { - server := newTestServer() - taskID := "task_disconnect_same_task_backlog" - startedSignals := make(chan int, maxPendingStreamRequests) - releaseFirst := make(chan struct{}) - - var startedMu sync.Mutex - startedCount := 0 - server.handlers["test.same.task.blocking"] = func(params map[string]any) (any, *rpcError) { - startedMu.Lock() - startedCount++ - callIndex := startedCount - startedMu.Unlock() - - startedSignals <- callIndex - if callIndex == 1 { - <-releaseFirst - } - - return map[string]any{ - "task": map[string]any{ - "task_id": stringValue(params, "task_id", ""), - }, - }, nil - } - - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen loopback: %v", err) - } - defer listener.Close() - - acceptDone := make(chan error, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - acceptDone <- err - return - } - server.handleStreamConn(conn) - acceptDone <- nil - }() - - right, err := net.Dial("tcp", listener.Addr().String()) - if err != nil { - t.Fatalf("dial loopback: %v", err) - } - - encoder := json.NewEncoder(right) - for index := 0; index < maxPendingStreamRequests; index++ { - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(fmt.Sprintf(`"req-same-task-disconnect-%d"`, index)), - Method: "test.same.task.blocking", - Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - }), - } - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode same-task request %d: %v", index, err) - } - } - - select { - case callIndex := <-startedSignals: - if callIndex != 1 { - t.Fatalf("expected the first same-task request to start first, got call %d", callIndex) - } - case <-time.After(2 * time.Second): - t.Fatal("expected the first same-task request to start") - } - - select { - case callIndex := <-startedSignals: - t.Fatalf("expected same-task backlog to stay queued behind the first request, got call %d", callIndex) - case <-time.After(250 * time.Millisecond): - } - - if err := right.Close(); err != nil { - t.Fatalf("close client stream: %v", err) - } - - close(releaseFirst) - - select { - case callIndex := <-startedSignals: - t.Fatalf("expected disconnected same-task backlog not to start after release, got call %d", callIndex) - case <-time.After(500 * time.Millisecond): - } - - startedMu.Lock() - if startedCount != 1 { - startedMu.Unlock() - t.Fatalf("expected only the first same-task request to dispatch before disconnect, got %d", startedCount) - } - startedMu.Unlock() - - select { - case err := <-acceptDone: - if err != nil && !errors.Is(err, net.ErrClosed) { - t.Fatalf("accept loopback: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("expected loopback stream to shut down after same-task backlog release") - } -} - -func TestHandleStreamConnKeepsHealthyIdleSameTaskBacklogAlive(t *testing.T) { - server := newTestServer() - taskID := "task_idle_same_task_backlog" - startedSignals := make(chan int, maxPendingStreamRequests) - releaseFirst := make(chan struct{}) - - var startedMu sync.Mutex - startedCount := 0 - server.handlers["test.same.task.healthy"] = func(params map[string]any) (any, *rpcError) { - startedMu.Lock() - startedCount++ - callIndex := startedCount - startedMu.Unlock() - - startedSignals <- callIndex - if callIndex == 1 { - <-releaseFirst - } - - return map[string]any{ - "task": map[string]any{ - "task_id": stringValue(params, "task_id", ""), - }, - }, nil - } - - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen loopback: %v", err) - } - defer listener.Close() - - acceptDone := make(chan error, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - acceptDone <- err - return - } - server.handleStreamConn(conn) - acceptDone <- nil - }() - - right, err := net.Dial("tcp", listener.Addr().String()) - if err != nil { - t.Fatalf("dial loopback: %v", err) - } - defer right.Close() - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - for index := 0; index < maxPendingStreamRequests; index++ { - request := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(fmt.Sprintf(`"req-same-task-healthy-%d"`, index)), - Method: "test.same.task.healthy", - Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - }), - } - if err := encoder.Encode(request); err != nil { - t.Fatalf("encode same-task request %d: %v", index, err) - } - } - - select { - case callIndex := <-startedSignals: - if callIndex != 1 { - t.Fatalf("expected the first same-task request to start first, got call %d", callIndex) - } - case <-time.After(2 * time.Second): - t.Fatal("expected the first same-task request to start") - } - - select { - case callIndex := <-startedSignals: - t.Fatalf("expected same-task backlog to stay queued behind the first request, got call %d", callIndex) - case <-time.After(250 * time.Millisecond): - } - - close(releaseFirst) - - if err := right.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - defer func() { - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear read deadline: %v", err) - } - }() - - var firstEnvelope map[string]any - if err := decoder.Decode(&firstEnvelope); err != nil { - t.Fatalf("decode first same-task response: %v", err) - } - if firstEnvelope["id"] == nil { - t.Fatalf("expected the first same-task response envelope, got %+v", firstEnvelope) - } - if firstEnvelope["error"] != nil { - t.Fatalf("expected first same-task response to succeed, got %+v", firstEnvelope) - } - - select { - case callIndex := <-startedSignals: - if callIndex != 2 { - t.Fatalf("expected the second same-task request to dispatch next, got call %d", callIndex) - } - case <-time.After(2 * time.Second): - t.Fatal("expected the second same-task request to dispatch after the first response") - } - - var secondEnvelope map[string]any - if err := decoder.Decode(&secondEnvelope); err != nil { - t.Fatalf("decode second same-task response: %v", err) - } - if secondEnvelope["id"] == nil { - t.Fatalf("expected the second same-task response envelope, got %+v", secondEnvelope) - } - if secondEnvelope["error"] != nil { - t.Fatalf("expected same-task backlog to stay on the healthy shared stream, got %+v", secondEnvelope) - } - - select { - case err := <-acceptDone: - if err != nil && !errors.Is(err, net.ErrClosed) { - t.Fatalf("accept loopback: %v", err) - } - case <-time.After(250 * time.Millisecond): - } -} - -func TestHandleStreamConnSerializesTaskStartingRequestsOnSharedConnection(t *testing.T) { - testCases := []struct { - name string - method string - firstParams map[string]any - secondParams map[string]any - }{ - { - name: "input submit", - method: "agent.input.submit", - firstParams: map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_serialized_submit_first", - "client_time": "2026-05-10T10:00:00Z", - }, - "session_id": "sess_serialized_submit", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "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", - "input_mode": "text", - }, - "context": map[string]any{}, - }, - }, - { - name: "task start", - method: "agent.task.start", - firstParams: map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_serialized_start_first", - "client_time": "2026-05-10T10:00:00Z", - }, - "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", - "input": map[string]any{ - "type": "text_selection", - "text": "second selection", - }, - }, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - server := newTestServer() - firstStarted := make(chan struct{}) - releaseFirst := make(chan struct{}) - var callCount int - var callMu sync.Mutex - - server.handlers[testCase.method] = func(_ map[string]any) (any, *rpcError) { - callMu.Lock() - callCount++ - currentCall := callCount - callMu.Unlock() - - if currentCall == 1 { - select { - case <-firstStarted: - default: - close(firstStarted) - } - <-releaseFirst - } - - return map[string]any{ - "task": map[string]any{ - "task_id": fmt.Sprintf("task_serial_%d", currentCall), - }, - }, nil - } - - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - firstRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-starting-1"`), - Method: testCase.method, - Params: mustMarshal(t, testCase.firstParams), - } - secondRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-starting-2"`), - Method: testCase.method, - Params: mustMarshal(t, testCase.secondParams), - } - - if err := encoder.Encode(firstRequest); err != nil { - t.Fatalf("encode first request: %v", err) - } - - select { - case <-firstStarted: - case <-time.After(500 * time.Millisecond): - t.Fatal("expected first task-starting request to begin running") - } - - if err := encoder.Encode(secondRequest); err != nil { - t.Fatalf("encode second request: %v", err) - } - - type decodeResult struct { - envelope map[string]any - err error - } - firstResponseCh := make(chan decodeResult, 1) - go func() { - var envelope map[string]any - err := decoder.Decode(&envelope) - firstResponseCh <- decodeResult{envelope: envelope, err: err} - }() - - select { - case result := <-firstResponseCh: - if result.err != nil { - t.Fatalf("expected no response before the first task-starting request finishes, got %v", result.err) - } - t.Fatalf("expected second task-starting request to stay queued until the first finishes, got %+v", result.envelope) - case <-time.After(250 * time.Millisecond): - } - - close(releaseFirst) - - seenResponses := map[string]bool{} - select { - case result := <-firstResponseCh: - if result.err != nil { - t.Fatalf("decode first serialized response envelope: %v", result.err) - } - id, _ := result.envelope["id"].(string) - if id == "req-task-starting-1" || id == "req-task-starting-2" { - seenResponses[id] = true - } - case <-time.After(1 * time.Second): - t.Fatal("expected first queued task-starting request to finish after release") - } - - if err := right.SetReadDeadline(time.Now().Add(1 * time.Second)); err != nil { - t.Fatalf("set response deadline: %v", err) - } - for len(seenResponses) < 2 { - var envelope map[string]any - if err := decoder.Decode(&envelope); err != nil { - t.Fatalf("decode serialized response envelope: %v", err) - } - id, _ := envelope["id"].(string) - if id == "req-task-starting-1" || id == "req-task-starting-2" { - seenResponses[id] = true - } - } - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear response deadline: %v", err) - } - }) - } -} - -func TestHandleStreamConnReplaysLateTaskNotificationsBeforeQueuedSameTaskFollowUp(t *testing.T) { - server := newTestServer() - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_late_task_replay", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "queue notifications for shared stream replay", - }, - }) - if err != nil { - t.Fatalf("seed task.start: %v", err) - } - taskID := startResult["task"].(map[string]any)["task_id"].(string) - notifications, err := server.orchestrator.PendingNotifications(taskID) - if err != nil || len(notifications) == 0 { - t.Fatalf("expected seeded task to queue notifications, err=%v notifications=%+v", err, notifications) - } - - firstReturned := make(chan struct{}) - server.handlers["agent.task.start"] = func(_ map[string]any) (any, *rpcError) { - select { - case <-firstReturned: - default: - close(firstReturned) - } - return map[string]any{ - "task": map[string]any{ - "task_id": taskID, - }, - }, nil - } - server.handlers["test.followup.task"] = func(params map[string]any) (any, *rpcError) { - return map[string]any{ - "task": map[string]any{ - "task_id": stringValue(params, "task_id", ""), - }, - }, nil - } - - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - firstRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-late-task-starter"`), - Method: "agent.task.start", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_late_task_starter", - "client_time": "2026-05-10T10:00:00Z", - }, - "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", - }, - }), - } - if err := encoder.Encode(firstRequest); err != nil { - t.Fatalf("encode first late-task response request: %v", err) - } - - select { - case <-firstReturned: - case <-time.After(500 * time.Millisecond): - t.Fatal("expected first task-starting request to finish dispatch") - } - - secondRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-late-task-followup"`), - Method: "test.followup.task", - Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - }), - } - if err := encoder.Encode(secondRequest); err != nil { - t.Fatalf("encode same-task follow-up request: %v", err) - } - - if err := right.SetReadDeadline(time.Now().Add(1500 * time.Millisecond)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - defer func() { - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear read deadline: %v", err) - } - }() - - var firstEnvelope map[string]any - if err := decoder.Decode(&firstEnvelope); err != nil { - t.Fatalf("decode first response envelope: %v", err) - } - if firstID, _ := firstEnvelope["id"].(string); firstID != "req-late-task-starter" { - t.Fatalf("expected first envelope to be the starter response, got %+v", firstEnvelope) - } - - notificationBeforeFollowUp := false - followUpResponseSeen := false - for index := 0; index < 12; index++ { - var envelope map[string]any - if err := decoder.Decode(&envelope); err != nil { - t.Fatalf("decode shared stream envelope: %v", err) - } - if envelopeID, _ := envelope["id"].(string); envelopeID == "req-late-task-followup" { - followUpResponseSeen = true - break - } - if method, _ := envelope["method"].(string); method != "" { - notificationBeforeFollowUp = true - } - } - if !followUpResponseSeen { - t.Fatal("expected follow-up response to arrive on the shared stream") - } - if !notificationBeforeFollowUp { - t.Fatal("expected buffered notifications for the started task to replay before the queued same-task follow-up response") - } -} - -func TestHandleStreamConnTaskListDoesNotStealBufferedNotifications(t *testing.T) { - server := newTestServer() - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_task_list_replay_owner", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "queue notifications for task.list replay ownership", - }, - }) - if err != nil { - t.Fatalf("seed task.start: %v", err) - } - taskID := startResult["task"].(map[string]any)["task_id"].(string) - notifications, err := server.orchestrator.PendingNotifications(taskID) - if err != nil || len(notifications) == 0 { - t.Fatalf("expected seeded task to queue notifications, err=%v notifications=%+v", err, notifications) - } - - listStarted := make(chan struct{}) - allowListReturn := make(chan struct{}) - server.handlers["agent.task.list"] = func(_ map[string]any) (any, *rpcError) { - select { - case <-listStarted: - default: - close(listStarted) - } - <-allowListReturn - return map[string]any{ - "items": []any{ - map[string]any{"task_id": taskID}, - }, - }, nil - } - server.handlers["test.followup.task"] = func(params map[string]any) (any, *rpcError) { - return map[string]any{ - "task": map[string]any{ - "task_id": stringValue(params, "task_id", ""), - }, - }, nil - } - - left, right := net.Pipe() - defer left.Close() - defer right.Close() - - go server.handleStreamConn(left) - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - if err := encoder.Encode(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-list-owned"`), - Method: "agent.task.list", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_task_list_owned"), - }), - }); err != nil { - t.Fatalf("encode task.list request: %v", err) - } - - select { - case <-listStarted: - case <-time.After(500 * time.Millisecond): - t.Fatal("expected task.list request to start dispatch") - } - - if err := encoder.Encode(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-list-followup"`), - Method: "test.followup.task", - Params: mustMarshal(t, map[string]any{ - "task_id": taskID, - }), - }); err != nil { - t.Fatalf("encode follow-up request: %v", err) - } - close(allowListReturn) - - if err := right.SetReadDeadline(time.Now().Add(1500 * time.Millisecond)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - defer func() { - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear read deadline: %v", err) - } - }() - - taskListResponseSeen := false - followUpResponseSeen := false - notificationAfterFollowUp := false - for index := 0; index < 16; index++ { - var envelope map[string]any - if err := decoder.Decode(&envelope); err != nil { - t.Fatalf("decode shared stream envelope: %v", err) - } - if envelopeID, _ := envelope["id"].(string); envelopeID == "req-task-list-owned" { - taskListResponseSeen = true - } else if envelopeID, _ := envelope["id"].(string); envelopeID == "req-task-list-followup" { - followUpResponseSeen = true - } else if method, _ := envelope["method"].(string); method != "" { - if !followUpResponseSeen { - t.Fatalf("expected task.list not to replay %s before the follow-up response, got %+v", method, envelope) - } - notificationAfterFollowUp = true - } - if taskListResponseSeen && followUpResponseSeen && notificationAfterFollowUp { - break - } - } - if !taskListResponseSeen { - t.Fatal("expected task.list response to arrive on the shared stream") - } - if !followUpResponseSeen { - t.Fatal("expected follow-up response to arrive on the shared stream") - } - if !notificationAfterFollowUp { - t.Fatal("expected the owning follow-up request to replay buffered notifications after its response") - } -} - -func TestStreamTaskCoordinatorReleasesIdleTaskLocks(t *testing.T) { - coordinator := newStreamTaskCoordinator() - coordinator.withTaskLocks(map[string]bool{ - "task_cleanup": true, - }, func() {}) - - coordinator.mu.Lock() - defer coordinator.mu.Unlock() - if len(coordinator.locks) != 0 { - t.Fatalf("expected idle task locks to be released, got %+v", coordinator.locks) - } -} - -func TestHandleStreamConnKeepsQueuedReadsResponsiveWhileLoopTaskRuns(t *testing.T) { - modelClient := &selectiveWaitLoopModelClient{ - stubLoopModelClient: stubLoopModelClient{ - toolResult: model.ToolCallResult{ - OutputText: "Concurrent stream finished.", - }, - generateToolWait: make(chan struct{}), - generateToolSeen: make(chan struct{}), - }, - } - server := newTestServerWithModelClient(modelClient) - - startTask := func(sessionID string) string { - t.Helper() - result, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": sessionID, - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "inspect this workspace", - }, - }) - if err != nil { - t.Fatalf("seed task.start for %s: %v", sessionID, err) - } - return result["task"].(map[string]any)["task_id"].(string) - } - - taskA := startTask("sess_pipe_queue_a") - taskB := startTask("sess_pipe_queue_b") - modelClient.blockedTaskID = taskA - - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen loopback: %v", err) - } - defer listener.Close() - - acceptDone := make(chan error, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - acceptDone <- err - return - } - server.handleStreamConn(conn) - acceptDone <- nil - }() - - right, err := net.Dial("tcp", listener.Addr().String()) - if err != nil { - t.Fatalf("dial loopback: %v", err) - } - defer func() { - _ = right.Close() - select { - case err := <-acceptDone: - if err != nil && !errors.Is(err, net.ErrClosed) { - t.Fatalf("accept loopback: %v", err) - } - case <-time.After(500 * time.Millisecond): - t.Fatal("expected loopback stream to shut down") - } - }() - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - confirmRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-loop-blocked"`), - Method: "agent.task.confirm", - Params: mustMarshal(t, map[string]any{ - "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{}, - }, - }), - } - if err := encoder.Encode(confirmRequest); err != nil { - t.Fatalf("encode blocked confirm request: %v", err) - } - - select { - case <-modelClient.generateToolSeen: - case <-time.After(500 * time.Millisecond): - t.Fatal("expected blocked loop task to start model execution") - } - - detailRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-detail-queued"`), - Method: "agent.task.detail.get", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_task_detail_queued", - "client_time": "2026-05-10T10:00:00Z", - }, - "task_id": taskB, - }), - } - if err := encoder.Encode(detailRequest); err != nil { - t.Fatalf("encode queued detail request: %v", err) - } - - if err := right.SetReadDeadline(time.Now().Add(750 * time.Millisecond)); err != nil { - t.Fatalf("set queued response deadline: %v", err) - } - defer func() { - if err := right.SetReadDeadline(time.Time{}); err != nil { - t.Fatalf("clear queued response deadline: %v", err) - } - }() - - queuedResponseSeen := false - for index := 0; index < 12; index++ { - var envelope map[string]any - if err := decoder.Decode(&envelope); err != nil { - break - } - responseID, _ := envelope["id"].(string) - if responseID != "req-task-detail-queued" { - continue - } - result, ok := envelope["result"].(map[string]any) - if !ok { - t.Fatalf("expected queued detail success envelope, got %+v", envelope) - } - data, ok := result["data"].(map[string]any) - if !ok { - t.Fatalf("expected queued detail response payload, got %+v", envelope) - } - task, ok := data["task"].(map[string]any) - if !ok || task["task_id"] != taskB { - t.Fatalf("expected queued detail response for %s, got %+v", taskB, envelope) - } - queuedResponseSeen = true - break - } - - close(modelClient.generateToolWait) - - if !queuedResponseSeen { - t.Fatal("expected queued task detail request to complete before the blocked loop response") - } -} - -func TestHandleStreamConnSerializesConcurrentRequestsForSameTask(t *testing.T) { - modelClient := &selectiveWaitLoopModelClient{ - stubLoopModelClient: stubLoopModelClient{ - toolResult: model.ToolCallResult{ - OutputText: "Same-task stream finished.", - }, - generateToolWait: make(chan struct{}), - generateToolSeen: make(chan struct{}), - }, - } - server := newTestServerWithModelClient(modelClient) - - result, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_pipe_same_task", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "inspect this workspace", - }, - }) - if err != nil { - t.Fatalf("seed task.start: %v", err) - } - taskID := result["task"].(map[string]any)["task_id"].(string) - modelClient.blockedTaskID = taskID - - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen loopback: %v", err) - } - defer listener.Close() - - acceptDone := make(chan error, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - acceptDone <- err - return - } - server.handleStreamConn(conn) - acceptDone <- nil - }() - - right, err := net.Dial("tcp", listener.Addr().String()) - if err != nil { - t.Fatalf("dial loopback: %v", err) - } - defer func() { - _ = right.Close() - select { - case err := <-acceptDone: - if err != nil && !errors.Is(err, net.ErrClosed) { - t.Fatalf("accept loopback: %v", err) - } - case <-time.After(500 * time.Millisecond): - t.Fatal("expected loopback stream to shut down") - } - }() - - encoder := json.NewEncoder(right) - decoder := json.NewDecoder(right) - envelopeCh := make(chan map[string]any, 32) - decodeErrCh := make(chan error, 1) - go func() { - for { - var envelope map[string]any - if err := decoder.Decode(&envelope); err != nil { - decodeErrCh <- err - return - } - envelopeCh <- envelope - } - }() - confirmRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-loop-same-task"`), - Method: "agent.task.confirm", - Params: mustMarshal(t, map[string]any{ - "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{}, - }, - }), - } - if err := encoder.Encode(confirmRequest); err != nil { - t.Fatalf("encode blocked confirm request: %v", err) - } - - select { - case <-modelClient.generateToolSeen: - case <-time.After(500 * time.Millisecond): - t.Fatal("expected blocked same-task loop to start model execution") - } - - detailRequest := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-detail-same-task"`), - Method: "agent.task.detail.get", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_task_detail_same_task", - "client_time": "2026-05-10T10:00:00Z", - }, - "task_id": taskID, - }), - } - if err := encoder.Encode(detailRequest); err != nil { - t.Fatalf("encode same-task detail request: %v", err) - } - - detailResponseSeenEarly := false - earlyWindow := time.After(250 * time.Millisecond) - for !detailResponseSeenEarly { - select { - case envelope := <-envelopeCh: - responseID, _ := envelope["id"].(string) - if responseID == "req-task-detail-same-task" { - detailResponseSeenEarly = true - } - case err := <-decodeErrCh: - t.Fatalf("decode same-task early envelope: %v", err) - case <-earlyWindow: - goto afterEarlyWindow - } - } - -afterEarlyWindow: - - if detailResponseSeenEarly { - t.Fatal("expected same-task detail request to wait until the blocked loop request finishes") - } - - close(modelClient.generateToolWait) - detailResponseSeen := false - postUnblockDeadline := time.After(3 * time.Second) - for !detailResponseSeen { - select { - case envelope := <-envelopeCh: - responseID, _ := envelope["id"].(string) - if responseID == "req-task-detail-same-task" { - detailResponseSeen = true - } - case err := <-decodeErrCh: - t.Fatalf("decode same-task post-unblock envelope: %v", err) - case <-postUnblockDeadline: - t.Fatal("expected same-task detail response after the blocked loop request completed") - } - } -} - -func TestDispatchTaskStartIgnoresUnsupportedIntentField(t *testing.T) { - server := newTestServer() - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-start-ignore-intent"`), - Method: "agent.task.start", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_task_start_ignore_intent", - "client_time": "2026-05-10T10:00:00Z", - }, - "session_id": "sess_ignore_intent", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "select this content", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, - }, - }), - }) - - success, ok := response.(successEnvelope) - if !ok { - t.Fatalf("expected success response envelope, got %#v", response) - } - task := success.Result.Data.(map[string]any)["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) - } - intentValue, ok := task["intent"].(map[string]any) - if !ok || intentValue["name"] != "agent_loop" { - t.Fatalf("expected task.start to rely on backend suggestion instead of request intent, got %+v", task["intent"]) - } -} - -func TestDispatchTaskStartFileInstructionSkipsIntentConfirmation(t *testing.T) { - server := newTestServerWithModelClient(&stubLoopModelClient{}) - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-start-file-instruction"`), - Method: "agent.task.start", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_task_start_file_instruction", - "client_time": "2026-05-10T10:00:00Z", - }, - "session_id": "sess_file_instruction_rpc", - "source": "floating_ball", - "trigger": "file_drop", - "input": map[string]any{ - "type": "file", - "text": "帮我看看这里面有什么", - "files": []string{"workspace/MyToDos_Vue"}, - }, - "options": map[string]any{ - "confirm_required": false, - }, - "delivery": map[string]any{ - "preferred": "bubble", - "fallback": "task_detail", - }, - }), - }) - - success, ok := response.(successEnvelope) - if !ok { - t.Fatalf("expected success response envelope, got %#v", response) - } - result := success.Result.Data.(map[string]any) - 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) - } - if task["source_type"] != "dragged_file" { - t.Fatalf("expected dragged_file source type, got %+v", task) - } - intentValue, ok := task["intent"].(map[string]any) - if !ok || intentValue["name"] != "agent_loop" { - t.Fatalf("expected task.start to keep backend agent_loop suggestion, got %+v", task["intent"]) - } - if result["delivery_result"] == nil { - t.Fatal("expected instructed file start to return delivery_result") - } -} - -// TestHandleDebugEventsReturnsQueuedNotifications verifies that queued -// notifications can be fetched through the debug events endpoint. -func TestHandleDebugEventsReturnsQueuedNotifications(t *testing.T) { - server := newTestServer() - result, err := startTaskForTest(server.orchestrator, 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", - }, - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - - taskID := result["task"].(map[string]any)["task_id"].(string) - recorder := httptest.NewRecorder() - request := httptest.NewRequest("GET", "/events?task_id="+taskID, nil) - server.handleDebugEvents(recorder, request) - - if recorder.Code != 200 { - t.Fatalf("expected 200 status, got %d", recorder.Code) - } - - var payload map[string]any - if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { - t.Fatalf("decode payload: %v", err) - } - - items := payload["items"].([]any) - if len(items) == 0 { - t.Fatal("expected queued notifications to be returned") - } -} - -func TestHandleHTTPRPCAllowsLoopbackStyleOrigins(t *testing.T) { - server := newTestServer() - origins := []string{ - "http://localhost:5173", - "https://127.0.0.1:5173", - "tauri://localhost", - "https://tauri.localhost", - } - - for _, origin := range origins { - t.Run(origin, func(t *testing.T) { - recorder := httptest.NewRecorder() - request := httptest.NewRequest("OPTIONS", "/rpc", nil) - request.Header.Set("Origin", origin) - - server.handleHTTPRPC(recorder, request) - - if recorder.Code != 204 { - t.Fatalf("expected 204 status, got %d", recorder.Code) - } - if recorder.Header().Get("Access-Control-Allow-Origin") != origin { - t.Fatalf("expected CORS allow origin %q, got %q", origin, recorder.Header().Get("Access-Control-Allow-Origin")) - } - }) - } -} - -func TestHandleHTTPRPCRejectsNonLoopbackOrigins(t *testing.T) { - server := newTestServer() - recorder := httptest.NewRecorder() - request := httptest.NewRequest("OPTIONS", "/rpc", nil) - request.Header.Set("Origin", "https://example.com") - - server.handleHTTPRPC(recorder, request) - - if recorder.Code != 204 { - t.Fatalf("expected 204 status, got %d", recorder.Code) - } - if recorder.Header().Get("Access-Control-Allow-Origin") != "" { - t.Fatalf("expected no CORS allow origin for non-loopback request, got %q", recorder.Header().Get("Access-Control-Allow-Origin")) - } -} - -func TestDispatchTaskDetailGetIncludesActiveApprovalAnchor(t *testing.T) { - server := newTestServer() - - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_detail_rpc", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "rpc task detail should expose active approval anchor", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - - taskID := startResult["task"].(map[string]any)["task_id"].(string) - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-detail-anchor"`), - Method: "agent.task.detail.get", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_task_detail_anchor", - "client_time": "2026-05-10T10:00:00Z", - }, - "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) - approvalRequest, ok := data["approval_request"].(map[string]any) - if !ok { - t.Fatalf("expected approval_request in rpc result, got %+v", data["approval_request"]) - } - if approvalRequest["task_id"] != taskID { - t.Fatalf("expected approval_request task_id %s, got %+v", taskID, approvalRequest) - } - - securitySummary := data["security_summary"].(map[string]any) - if numericValue(t, securitySummary["pending_authorizations"]) != 1 { - t.Fatalf("expected pending_authorizations 1 in rpc result, got %+v", securitySummary["pending_authorizations"]) - } - if securitySummary["latest_restore_point"] != nil { - t.Fatalf("expected latest_restore_point nil in rpc result, got %+v", securitySummary["latest_restore_point"]) - } -} - -func TestDispatchTaskDetailGetOmitsApprovalAnchorForCompletedTask(t *testing.T) { - server := newTestServer() - - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_detail_rpc_done", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "rpc task detail should omit anchor for completed task", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - - taskID := startResult["task"].(map[string]any)["task_id"].(string) - if _, ok := server.orchestrator.RunEngine().CompleteTask(taskID, map[string]any{"type": "task_detail", "payload": map[string]any{"task_id": taskID}}, map[string]any{"task_id": taskID, "type": "result", "text": "done"}, nil); !ok { - t.Fatal("expected runtime task completion to succeed") - } - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-detail-no-anchor"`), - Method: "agent.task.detail.get", - Params: mustMarshal(t, map[string]any{ - "request_meta": map[string]any{ - "trace_id": "trace_task_detail_no_anchor", - "client_time": "2026-05-10T10:00:00Z", - }, - "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) - if data["approval_request"] != nil { - t.Fatalf("expected approval_request to be nil for completed task, got %+v", data["approval_request"]) - } - - securitySummary := data["security_summary"].(map[string]any) - if numericValue(t, securitySummary["pending_authorizations"]) != 0 { - t.Fatalf("expected pending_authorizations 0 in rpc result, got %+v", securitySummary["pending_authorizations"]) - } - if _, ok := securitySummary["latest_restore_point"].(map[string]any); !ok { - t.Fatalf("expected latest_restore_point object for completed task, got %+v", securitySummary["latest_restore_point"]) - } -} - -func TestDispatchMapsTaskControlStatusErrors(t *testing.T) { - server := newTestServer() - - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "still waiting for intent confirmation", - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - - taskID := startResult["task"].(map[string]any)["task_id"].(string) - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-control-invalid"`), - Method: "agent.task.control", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_task_control_invalid_status"), - "task_id": taskID, - "action": "pause", - }), - }) - - errEnvelope, ok := response.(errorEnvelope) - if !ok { - t.Fatalf("expected error response envelope, got %#v", response) - } - if errEnvelope.Error.Code != 1001004 || errEnvelope.Error.Message != "TASK_STATUS_INVALID" { - t.Fatalf("expected TASK_STATUS_INVALID mapping, got code=%d message=%s", errEnvelope.Error.Code, errEnvelope.Error.Message) - } -} - -func TestDispatchMapsTaskControlFinishedErrors(t *testing.T) { - server := newTestServer() - - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "completed task for rpc error mapping", - }, - "intent": map[string]any{ - "name": "summarize", - "arguments": map[string]any{ - "style": "key_points", - }, - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - - taskID := startResult["task"].(map[string]any)["task_id"].(string) - if _, ok := server.orchestrator.RunEngine().CompleteTask(taskID, map[string]any{"type": "task_detail", "payload": map[string]any{"task_id": taskID}}, map[string]any{"task_id": taskID, "type": "result", "text": "done"}, nil); !ok { - t.Fatal("expected runtime task completion to succeed") - } - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-control-finished"`), - Method: "agent.task.control", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_task_control_finished"), - "task_id": taskID, - "action": "cancel", - }), - }) - - errEnvelope, ok := response.(errorEnvelope) - if !ok { - t.Fatalf("expected error response envelope, got %#v", response) - } - if errEnvelope.Error.Code != 1001005 || errEnvelope.Error.Message != "TASK_ALREADY_FINISHED" { - t.Fatalf("expected TASK_ALREADY_FINISHED mapping, got code=%d message=%s", errEnvelope.Error.Code, errEnvelope.Error.Message) - } -} - -func TestDispatchReturnsSecurityAuditList(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "audit.db"))) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - err := storageService.AuditWriter().WriteAuditRecord(context.Background(), audit.Record{ - AuditID: "audit_001", - TaskID: "task_001", - Type: "file", - Action: "write_file", - Summary: "stored audit record", - Target: "workspace/result.md", - Result: "success", - CreatedAt: "2026-04-08T10:00:00Z", - }) - if err != nil { - t.Fatalf("write audit record: %v", err) - } - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-security-audit-list"`), - Method: "agent.security.audit.list", - 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) - if len(items) != 1 { - t.Fatalf("expected one audit item, got %d", len(items)) - } - if items[0]["audit_id"] != "audit_001" { - t.Fatalf("expected stored audit_001, got %+v", items[0]) - } -} - -func TestDispatchReturnsSecurityRestorePointsList(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "restore.db"))) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - err := storageService.RecoveryPointWriter().WriteRecoveryPoint(context.Background(), checkpoint.RecoveryPoint{ - RecoveryPointID: "rp_001", - TaskID: "task_001", - Summary: "stored recovery point", - CreatedAt: "2026-04-08T10:00:00Z", - Objects: []string{"workspace/result.md"}, - }) - if err != nil { - t.Fatalf("write recovery point: %v", err) - } - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-security-restore-points-list"`), - Method: "agent.security.restore_points.list", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_security_restore_points"), - "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) - if len(items) != 1 { - t.Fatalf("expected one recovery point item, got %d", len(items)) - } - if items[0]["recovery_point_id"] != "rp_001" { - t.Fatalf("expected stored rp_001, got %+v", items[0]) - } -} - -func TestDispatchReturnsSecurityRestoreApplyResult(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "restore-apply.db"))) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_restore", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "restore runtime task", - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - taskID := startResult["task"].(map[string]any)["task_id"].(string) - - err = storageService.RecoveryPointWriter().WriteRecoveryPoint(context.Background(), checkpoint.RecoveryPoint{ - RecoveryPointID: "rp_001", - TaskID: taskID, - Summary: "stored recovery point", - CreatedAt: "2026-04-08T10:00:00Z", - Objects: []string{"workspace/result.md"}, - }) - if err != nil { - t.Fatalf("write recovery point: %v", err) - } - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-security-restore-apply"`), - Method: "agent.security.restore.apply", - 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) - if _, ok := data["applied"].(bool); !ok { - t.Fatalf("expected applied flag in restore result, got %+v", data) - } - if data["task"].(map[string]any)["status"] != "waiting_auth" { - t.Fatalf("expected restore apply rpc to enter waiting_auth, got %+v", data) - } - if data["recovery_point"].(map[string]any)["recovery_point_id"] != "rp_001" { - t.Fatalf("expected rp_001 restore result, got %+v", data) - } -} - -func TestDispatchReturnsNotepadUpdateResult(t *testing.T) { - server := newTestServer() - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-notepad-update"`), - Method: "agent.notepad.update", - 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) - notepadItem, ok := data["notepad_item"].(map[string]any) - if !ok { - t.Fatalf("expected notepad_item payload, got %+v", data) - } - if notepadItem["bucket"] != "upcoming" { - t.Fatalf("expected updated notepad item bucket upcoming, got %+v", notepadItem) - } - refreshGroups := data["refresh_groups"].([]string) - if len(refreshGroups) != 2 || refreshGroups[0] != "later" || refreshGroups[1] != "upcoming" { - t.Fatalf("expected refresh_groups to include source and target buckets, got %+v", refreshGroups) - } -} - -func TestDispatchReturnsTaskArtifactList(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "artifact-list.db"))) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - err := storageService.ArtifactStore().SaveArtifacts(context.Background(), []storage.ArtifactRecord{{ - ArtifactID: "art_rpc_001", - TaskID: "task_rpc_001", - ArtifactType: "generated_doc", - Title: "rpc-artifact.md", - Path: "workspace/rpc-artifact.md", - MimeType: "text/markdown", - DeliveryType: "workspace_document", - DeliveryPayloadJSON: `{"path":"workspace/rpc-artifact.md","task_id":"task_rpc_001"}`, - CreatedAt: "2026-04-14T10:00:00Z", - }}) - if err != nil { - t.Fatalf("write artifact: %v", err) - } - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-artifact-list"`), - Method: "agent.task.artifact.list", - 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) - if len(items) != 1 || items[0]["artifact_id"] != "art_rpc_001" { - t.Fatalf("expected artifact list item, got %+v", items) - } -} - -func TestDispatchReturnsTaskArtifactOpen(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "artifact-open.db"))) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - err := storageService.ArtifactStore().SaveArtifacts(context.Background(), []storage.ArtifactRecord{{ - ArtifactID: "art_rpc_open_001", - TaskID: "task_rpc_open_001", - ArtifactType: "generated_doc", - Title: "rpc-open.md", - Path: "workspace/rpc-open.md", - MimeType: "text/markdown", - DeliveryType: "open_file", - DeliveryPayloadJSON: `{"path":"workspace/rpc-open.md","task_id":"task_rpc_open_001"}`, - CreatedAt: "2026-04-14T10:05:00Z", - }}) - if err != nil { - t.Fatalf("write artifact: %v", err) - } - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-artifact-open"`), - Method: "agent.task.artifact.open", - 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) - if data["open_action"] != "open_file" { - t.Fatalf("expected open_file action, got %+v", data) - } - if data["artifact"].(map[string]any)["artifact_id"] != "art_rpc_open_001" { - t.Fatalf("expected opened artifact, got %+v", data) - } -} - -func TestDispatchMapsArtifactNotFoundErrors(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, orchestrator.ErrArtifactNotFound) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != 1005002 || rpcErr.Message != "ARTIFACT_NOT_FOUND" { - t.Fatalf("expected ARTIFACT_NOT_FOUND mapping, got code=%d message=%s", rpcErr.Code, rpcErr.Message) - } -} - -func TestDispatchReturnsDeliveryOpenForArtifact(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(platform.NewLocalStorageAdapter(filepath.Join(t.TempDir(), "delivery-open-artifact.db"))) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - err := storageService.ArtifactStore().SaveArtifacts(context.Background(), []storage.ArtifactRecord{{ - ArtifactID: "art_delivery_rpc_001", - TaskID: "task_delivery_rpc_001", - ArtifactType: "generated_doc", - Title: "delivery-rpc.md", - Path: "workspace/delivery-rpc.md", - MimeType: "text/markdown", - DeliveryType: "open_file", - DeliveryPayloadJSON: `{"path":"workspace/delivery-rpc.md","task_id":"task_delivery_rpc_001"}`, - CreatedAt: "2026-04-14T10:10:00Z", - }}) - if err != nil { - t.Fatalf("write artifact: %v", err) - } - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-delivery-open-artifact"`), - Method: "agent.delivery.open", - 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) - } - data := success.Result.Data.(map[string]any) - if data["open_action"] != "open_file" { - t.Fatalf("expected open_file action, got %+v", data) - } -} - -func TestDispatchReturnsDeliveryOpenForTaskResult(t *testing.T) { - server := newTestServer() - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_delivery_rpc", - "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", - }, - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - taskID := startResult["task"].(map[string]any)["task_id"].(string) - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-delivery-open-task"`), - Method: "agent.delivery.open", - 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) - } - data := success.Result.Data.(map[string]any) - if data["open_action"] != "task_detail" { - t.Fatalf("expected task_detail action, got %+v", data) - } -} - -func TestDispatchMapsSecurityAuditListStorageErrors(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, orchestrator.ErrStorageQueryFailed) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != 1005001 || rpcErr.Message != "SQLITE_WRITE_FAILED" { - t.Fatalf("expected SQLITE_WRITE_FAILED mapping, got code=%d message=%s", rpcErr.Code, rpcErr.Message) - } -} - -func TestDispatchMapsWrappedStructuredStoreErrors(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, fmt.Errorf("settings snapshot write failed: %w", storage.ErrStructuredStoreUnavailable)) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != 1005001 || rpcErr.Message != "SQLITE_WRITE_FAILED" { - t.Fatalf("expected wrapped structured store error to map to SQLITE_WRITE_FAILED, got code=%d message=%s", rpcErr.Code, rpcErr.Message) - } -} - -func TestDispatchMapsRecoveryPointNotFoundErrors(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, orchestrator.ErrRecoveryPointNotFound) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != 1005006 || rpcErr.Message != "RECOVERY_POINT_NOT_FOUND" { - t.Fatalf("expected RECOVERY_POINT_NOT_FOUND mapping, got code=%d message=%s", rpcErr.Code, rpcErr.Message) - } -} - -func TestDispatchMapsStrongholdErrors(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, orchestrator.ErrStrongholdAccessFailed) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != 1005004 || rpcErr.Message != "STRONGHOLD_ACCESS_FAILED" { - t.Fatalf("expected STRONGHOLD_ACCESS_FAILED mapping, got code=%d message=%s", rpcErr.Code, rpcErr.Message) - } -} - -func TestDispatchMapsModelProviderErrors(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, model.ErrModelProviderUnsupported) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != 1008001 || rpcErr.Message != "MODEL_PROVIDER_NOT_FOUND" { - t.Fatalf("expected MODEL_PROVIDER_NOT_FOUND mapping, got code=%d message=%s", rpcErr.Code, rpcErr.Message) - } -} - -func TestDispatchMapsModelConfigurationErrors(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, model.ErrOpenAIEndpointRequired) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != 1008002 || rpcErr.Message != "MODEL_NOT_ALLOWED" { - t.Fatalf("expected MODEL_NOT_ALLOWED mapping, got code=%d message=%s", rpcErr.Code, rpcErr.Message) - } -} - -func TestDispatchMapsModelRuntimeUnavailableErrors(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, model.ErrOpenAIRequestTimeout) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != 1008003 || rpcErr.Message != "MODEL_RUNTIME_UNAVAILABLE" { - t.Fatalf("expected MODEL_RUNTIME_UNAVAILABLE mapping, got code=%d message=%s", rpcErr.Code, rpcErr.Message) - } - - _, rpcErr = wrapOrchestratorResult(nil, &model.OpenAIHTTPStatusError{StatusCode: 503, Message: "upstream unavailable"}) - if rpcErr == nil || rpcErr.Code != 1008003 { - t.Fatalf("expected 5xx provider failure to map as runtime unavailable, got %+v", rpcErr) - } -} - -func TestDispatchMapsToolOutputInvalidExplicitly(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, tools.ErrToolOutputInvalid) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != 1003004 || rpcErr.Message != "TOOL_OUTPUT_INVALID" { - t.Fatalf("expected TOOL_OUTPUT_INVALID mapping, got code=%d message=%s", rpcErr.Code, rpcErr.Message) - } -} - -func TestDispatchMapsTaskInspectorSourceErrorsExplicitly(t *testing.T) { - tests := []struct { - name string - err error - code int - message string - }{ - {name: "outside workspace", err: fmt.Errorf("wrapped: %w", taskinspector.ErrInspectionSourceOutsideWorkspace), code: 1004003, message: "WORKSPACE_BOUNDARY_DENIED"}, - {name: "filesystem unavailable", err: fmt.Errorf("wrapped: %w", taskinspector.ErrInspectionFileSystemUnavailable), code: 1007006, message: "INSPECTION_FILESYSTEM_UNAVAILABLE"}, - {name: "source not found", err: fmt.Errorf("wrapped: %w", taskinspector.ErrInspectionSourceNotFound), code: 1007007, message: "INSPECTION_SOURCE_NOT_FOUND"}, - {name: "source unreadable", err: fmt.Errorf("wrapped: %w", taskinspector.ErrInspectionSourceUnreadable), code: 1007008, message: "INSPECTION_SOURCE_UNREADABLE"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, rpcErr := wrapOrchestratorResult(nil, test.err) - if rpcErr == nil { - t.Fatal("expected rpc error") - } - if rpcErr.Code != test.code || rpcErr.Message != test.message { - t.Fatalf("expected %s mapping, got code=%d message=%s", test.message, rpcErr.Code, rpcErr.Message) - } - }) - } -} - -func TestDispatchReturnsFormalTaskInspectorRunSourceErrors(t *testing.T) { - server := newTestServer() - pathPolicy, err := platform.NewLocalPathPolicy(filepath.Join(t.TempDir(), "rpc-task-inspector")) - if err != nil { - t.Fatalf("NewLocalPathPolicy returned error: %v", err) - } - server.orchestrator.WithTaskInspector(taskinspector.NewService(platform.NewLocalFileSystemAdapter(pathPolicy))) - tests := []struct { - name string - requestID string - targetSources []any - expectCode int - expectMessage string - }{ - {name: "missing source", requestID: "req-task-inspector-missing", targetSources: []any{"workspace/missing"}, expectCode: 1007007, expectMessage: "INSPECTION_SOURCE_NOT_FOUND"}, - {name: "outside workspace", requestID: "req-task-inspector-outside", targetSources: []any{"../outside"}, expectCode: 1004003, expectMessage: "WORKSPACE_BOUNDARY_DENIED"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(fmt.Sprintf(`"%s"`, test.requestID)), - Method: "agent.task_inspector.run", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_task_inspector_run"), - "target_sources": test.targetSources, - }), - }) - errEnvelope, ok := response.(errorEnvelope) - if !ok { - t.Fatalf("expected error response envelope, got %#v", response) - } - if errEnvelope.Error.Code != test.expectCode || errEnvelope.Error.Message != test.expectMessage { - t.Fatalf("expected %s to map to formal rpc error, got %+v", test.expectMessage, errEnvelope.Error) - } - }) - } -} - -func TestHandlerWrappersCoverRecommendationInspectorDashboardAndSecurityMethods(t *testing.T) { - server := newTestServer() - handlerCalls := []struct { - name string - invoke func() (any, *rpcError) - }{ - {name: "recommendation.get", invoke: func() (any, *rpcError) { return server.handleAgentRecommendationGet(map[string]any{}) }}, - {name: "recommendation.feedback.submit", invoke: func() (any, *rpcError) { return server.handleAgentRecommendationFeedbackSubmit(map[string]any{}) }}, - {name: "task_inspector.config.get", invoke: func() (any, *rpcError) { return server.handleAgentTaskInspectorConfigGet(nil) }}, - {name: "task_inspector.config.update", invoke: func() (any, *rpcError) { - return server.handleAgentTaskInspectorConfigUpdate(map[string]any{"task_sources": []any{"D:/workspace/todos"}, "inspection_interval": map[string]any{"unit": "minute", "value": 10}}) - }}, - {name: "task_inspector.run", invoke: func() (any, *rpcError) { return server.handleAgentTaskInspectorRun(map[string]any{}) }}, - {name: "notepad.list", invoke: func() (any, *rpcError) { return server.handleAgentNotepadList(map[string]any{}) }}, - {name: "notepad.convert_to_task", invoke: func() (any, *rpcError) { - return server.handleAgentNotepadConvertToTask(map[string]any{"item_id": "missing"}) - }}, - {name: "dashboard.overview.get", invoke: func() (any, *rpcError) { return server.handleAgentDashboardOverviewGet(map[string]any{}) }}, - {name: "dashboard.module.get", invoke: func() (any, *rpcError) { return server.handleAgentDashboardModuleGet(map[string]any{"module": "task"}) }}, - {name: "mirror.overview.get", invoke: func() (any, *rpcError) { return server.handleAgentMirrorOverviewGet(map[string]any{}) }}, - {name: "security.summary.get", invoke: func() (any, *rpcError) { return server.handleAgentSecuritySummaryGet(nil) }}, - {name: "security.pending.list", invoke: func() (any, *rpcError) { return server.handleAgentSecurityPendingList(map[string]any{}) }}, - {name: "security.respond", invoke: func() (any, *rpcError) { - return server.handleAgentSecurityRespond(map[string]any{"task_id": "missing", "decision": "approve"}) - }}, - {name: "settings.model.validate", invoke: func() (any, *rpcError) { return server.handleAgentSettingsModelValidate(map[string]any{}) }}, - } - for _, call := range handlerCalls { - data, rpcErr := call.invoke() - if data == nil && rpcErr == nil { - t.Fatalf("expected %s handler to return either data or rpc error", call.name) - } - if rpcErr != nil && rpcErr.TraceID == "" { - t.Fatalf("expected %s handler rpc error to include trace id, got %+v", call.name, rpcErr) - } - } -} - -func TestJSONRPCHandlerWrappersCoverPrimitiveDecodersAndTracingHelpers(t *testing.T) { - req := requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`1`), - Method: "agent.settings.update", - Params: mustMarshal(t, map[string]any{"request_meta": map[string]any{"trace_id": "trace_rpc_helpers"}, "enabled": true, "count": 3, "labels": []string{"a", "b"}}), - } - decoded, err := decodeRequest(strings.NewReader(string(mustMarshal(t, req)))) - if err != nil { - t.Fatalf("decodeRequest returned error: %v", err) - } - params, err := decodeParams(decoded.Params) - if err != nil { - t.Fatalf("decodeParams returned error: %v", err) - } - if requestTraceID(params) != "trace_rpc_helpers" || traceIDFromRequest(decoded.Params) != "trace_rpc_helpers" { - t.Fatalf("expected trace helpers to extract request trace ids, params=%+v", params) - } - if !boolValue(params, "enabled", false) || intValue(params, "count", 0) != 3 { - t.Fatalf("expected primitive decoders to round-trip values, params=%+v", params) - } - labels := stringSliceValue(params["labels"]) - if len(labels) != 2 || labels[1] != "b" { - t.Fatalf("expected stringSliceValue to decode labels, got %+v", labels) - } - if boolValue(nil, "enabled", false) || intValue(nil, "count", 0) != 0 || len(stringSliceValue(map[string]any{"labels": 7}["labels"])) != 0 { - t.Fatal("expected primitive decoders to handle nil and invalid inputs") - } - if _, err := decodeRequest(strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"agent.settings.get","params":`)); err == nil { - t.Fatal("expected malformed request to fail decodeRequest") - } - if _, err := decodeParams(json.RawMessage(`{"broken":`)); err == nil { - t.Fatal("expected malformed params to fail decodeParams") - } -} - -func TestDispatchReturnsSettingsGet(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(testStorageAdapter{databasePath: filepath.Join(t.TempDir(), "settings-get.db")}) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-settings-get"`), - Method: "agent.settings.get", - 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) - } - models := success.Result.Data.(map[string]any)["settings"].(map[string]any)["models"].(map[string]any) - credentials := models["credentials"].(map[string]any) - if _, ok := credentials["stronghold"].(map[string]any); !ok { - t.Fatalf("expected settings get to include stronghold status, got %+v", credentials) - } -} - -func TestDispatchReturnsSettingsUpdate(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(testStorageAdapter{databasePath: filepath.Join(t.TempDir(), "settings-update.db")}) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-settings-update"`), - Method: "agent.settings.update", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_settings_update"), - "models": map[string]any{ - "provider": "openai", - "api_key": "rpc-secret-key", - }, - }), - }) - success, ok := response.(successEnvelope) - 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) - 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) - } -} - -func TestDispatchReturnsSettingsModelValidate(t *testing.T) { - server := newTestServer() - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-settings-model-validate"`), - Method: "agent.settings.model.validate", - 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", - "model": "gpt-4.1-mini", - }, - }), - }) - success, ok := response.(successEnvelope) - if !ok { - t.Fatalf("expected success response envelope, got %#v", response) - } - data := success.Result.Data.(map[string]any) - if data["ok"] != false || data["status"] != "missing_api_key" { - t.Fatalf("expected structured validation failure result, got %+v", data) - } -} - -func TestDispatchReturnsPluginRuntimeList(t *testing.T) { - server := newTestServer() - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-plugin-runtime-list"`), - Method: "agent.plugin.runtime.list", - Params: mustMarshal(t, map[string]any{}), - }) - 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) - if len(items) == 0 { - t.Fatalf("expected plugin runtime query to return declared runtimes, got %+v", data) - } - manifest, ok := items[0]["manifest"].(map[string]any) - 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) - if len(metrics) == 0 { - t.Fatalf("expected plugin runtime query to return metric snapshots, got %+v", data) - } -} - -func TestDispatchReturnsPluginList(t *testing.T) { - server := newTestServer() - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-plugin-list"`), - Method: "agent.plugin.list", - Params: mustMarshal(t, map[string]any{ - "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) - } - data := success.Result.Data.(map[string]any) - items := data["items"].([]map[string]any) - if len(items) != 1 || items[0]["plugin_id"] != "ocr" { - t.Fatalf("expected plugin list query to return filtered ocr plugin, got %+v", data) - } -} - -func TestDispatchReturnsPluginDetail(t *testing.T) { - server, toolRegistry, pluginService := newTestServerWithDependencies(nil) - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-plugin-detail"`), - Method: "agent.plugin.detail.get", - Params: mustMarshal(t, map[string]any{ - "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) - if data["plugin"].(map[string]any)["plugin_id"] != "ocr" { - t.Fatalf("expected plugin detail query to resolve ocr plugin, got %+v", data) - } - runtime, ok := pluginService.RuntimeState(plugin.RuntimeKindWorker, "ocr_worker") - if !ok { - t.Fatalf("expected ocr worker runtime to exist") - } - toolItems := data["tools"].([]map[string]any) - if len(toolItems) != len(runtime.Capabilities) { - t.Fatalf("expected plugin detail query to return one contract per declared capability, got %+v", data) - } - for _, item := range toolItems { - toolName := item["tool_name"].(string) - tool, err := toolRegistry.Get(toolName) - if err != nil { - t.Fatalf("expected tool %q to exist in registry: %v", toolName, err) - } - metadata := tool.Metadata() - if item["display_name"] != metadata.DisplayName || item["source"] != string(metadata.Source) { - t.Fatalf("expected plugin detail payload to mirror registry metadata for %q, got %+v", toolName, item) - } - } -} - -func TestDispatchMapsTaskControlInvalidActionToInvalidParams(t *testing.T) { - server := newTestServer() - - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "text_selected_click", - "input": map[string]any{ - "type": "text_selection", - "text": "task for invalid action", - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - - taskID := startResult["task"].(map[string]any)["task_id"].(string) - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-control-invalid-action"`), - Method: "agent.task.control", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_task_control_invalid_action"), - "task_id": taskID, - "action": "skip", - }), - }) - - errEnvelope, ok := response.(errorEnvelope) - if !ok { - t.Fatalf("expected error response envelope, got %#v", response) - } - if errEnvelope.Error.Code != 1002001 || errEnvelope.Error.Message != "INVALID_PARAMS" { - t.Fatalf("expected INVALID_PARAMS mapping for unsupported action, got code=%d message=%s", errEnvelope.Error.Code, errEnvelope.Error.Message) - } -} - -func TestDispatchMapsTaskControlMissingTaskIDToInvalidParams(t *testing.T) { - server := newTestServer() - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-control-missing-task-id"`), - Method: "agent.task.control", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_task_control_missing_task_id"), - "action": "pause", - }), - }) - - errEnvelope, ok := response.(errorEnvelope) - if !ok { - t.Fatalf("expected error response envelope, got %#v", response) - } - if errEnvelope.Error.Code != 1002001 || errEnvelope.Error.Message != "INVALID_PARAMS" { - t.Fatalf("expected INVALID_PARAMS mapping for missing task_id, got code=%d message=%s", errEnvelope.Error.Code, errEnvelope.Error.Message) - } -} - -func TestDispatchMapsTaskControlMissingActionToInvalidParams(t *testing.T) { - server := newTestServer() - - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_demo", - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": "task control rpc validation", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - - taskID := startResult["task"].(map[string]any)["task_id"].(string) - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-control-missing-action"`), - Method: "agent.task.control", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_task_control_missing_action"), - "task_id": taskID, - }), - }) - - errEnvelope, ok := response.(errorEnvelope) - if !ok { - t.Fatalf("expected error response envelope, got %#v", response) - } - if errEnvelope.Error.Code != 1002001 || errEnvelope.Error.Message != "INVALID_PARAMS" { - t.Fatalf("expected INVALID_PARAMS mapping for missing action, got code=%d message=%s", errEnvelope.Error.Code, errEnvelope.Error.Message) - } -} - -func TestDispatchTaskListClampsPagingParams(t *testing.T) { - server := newTestServer() - - for index := 0; index < 25; index++ { - _, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": fmt.Sprintf("sess_rpc_task_list_%02d", index), - "source": "floating_ball", - "trigger": "hover_text_input", - "input": map[string]any{ - "type": "text", - "text": fmt.Sprintf("rpc task list clamp %02d", index), - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, - }, - }) - if err != nil { - t.Fatalf("start task %d: %v", index, err) - } - } - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-list-clamp"`), - Method: "agent.task.list", - Params: mustMarshal(t, map[string]any{ - "request_meta": rpcRequestMeta("trace_task_list_clamp"), - "group": "unfinished", - "limit": 0, - "offset": -5, - "sort_by": "updated_at", - "sort_order": "desc", - }), - }) - - 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) - if len(items) != 20 { - t.Fatalf("expected rpc task.list to clamp zero limit to 20 items, got %d", len(items)) - } - page := data["page"].(map[string]any) - if numericValue(t, page["limit"]) != 20 { - t.Fatalf("expected clamped rpc page limit 20, got %+v", page) - } - if numericValue(t, page["offset"]) != 0 { - t.Fatalf("expected clamped rpc page offset 0, got %+v", page) - } - if page["has_more"] != true { - t.Fatalf("expected rpc page has_more to remain true after clamping, got %+v", page) - } - if numericValue(t, page["total"]) != 25 { - t.Fatalf("expected rpc page total 25, got %+v", page) - } -} - -func TestDispatchTaskEventsListReturnsLoopEvents(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(testStorageAdapter{databasePath: filepath.Join(t.TempDir(), "rpc-loop-events.db")}) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - if err := storageService.LoopRuntimeStore().SaveEvents(context.Background(), []storage.EventRecord{{ - EventID: "evt_rpc_loop_001", - RunID: "run_rpc_loop_001", - TaskID: "task_rpc_loop_001", - StepID: "step_rpc_loop_001", - Type: "loop.completed", - Level: "info", - PayloadJSON: `{"stop_reason":"completed"}`, - CreatedAt: "2026-04-17T10:00:00Z", - }}); err != nil { - t.Fatalf("save loop events failed: %v", err) - } - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-events-list"`), - Method: "agent.task.events.list", - Params: mustMarshal(t, map[string]any{ - "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, - }), - }) - - 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) - if len(items) != 1 || items[0]["type"] != "loop.completed" { - t.Fatalf("expected rpc task events list to return loop.completed, got %+v", items) - } -} - -func TestDispatchTaskToolCallsListReturnsPersistedToolCalls(t *testing.T) { - server := newTestServer() - storageService := storage.NewService(testStorageAdapter{databasePath: filepath.Join(t.TempDir(), "rpc-tool-calls.db")}) - defer func() { _ = storageService.Close() }() - server.orchestrator.WithStorage(storageService) - if err := storageService.ToolCallStore().SaveToolCall(context.Background(), tools.ToolCallRecord{ - ToolCallID: "tool_call_rpc_001", - RunID: "run_rpc_tool_001", - TaskID: "task_rpc_tool_001", - ToolName: "read_file", - Status: tools.ToolCallStatusSucceeded, - Input: map[string]any{"path": "notes/source.txt"}, - Output: map[string]any{"path": "notes/source.txt"}, - DurationMS: 9, - }); err != nil { - t.Fatalf("save tool call failed: %v", err) - } - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-tool-calls-list"`), - Method: "agent.task.tool_calls.list", - 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) - } - data := success.Result.Data.(map[string]any) - items := data["items"].([]map[string]any) - 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) - } -} - -func TestDispatchTaskSteerReturnsUpdatedTask(t *testing.T) { - server := newTestServer() - startResult, err := startTaskForTest(server.orchestrator, map[string]any{ - "session_id": "sess_rpc_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, - }, - }, - }) - if err != nil { - t.Fatalf("start task: %v", err) - } - taskID := startResult["task"].(map[string]any)["task_id"].(string) - - response := server.dispatch(requestEnvelope{ - JSONRPC: "2.0", - ID: json.RawMessage(`"req-task-steer"`), - Method: "agent.task.steer", - 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) - } - data := success.Result.Data.(map[string]any) - if data["task"].(map[string]any)["task_id"] != taskID { - t.Fatalf("expected rpc task steer to keep task id, got %+v", data) - } -} - -func TestNewServerSkipsDebugHTTPWhenDisabled(t *testing.T) { - seed := newTestServer() - server := NewServer(serviceconfig.RPCConfig{ - Transport: "named_pipe", - NamedPipeName: `\\.\pipe\cialloclaw-rpc-disabled`, - DebugHTTPAddress: "", - }, seed.orchestrator) - - if server.debugHTTPServer != nil { - t.Fatal("expected explicit empty debug HTTP address to disable the diagnostics server") - } -} - -func newTestServer() *Server { - server, _, _ := newTestServerWithDependencies(nil) - return server -} - -func newTestServerWithModelClient(client model.Client) *Server { - server, _, _ := newTestServerWithDependencies(client) - return server -} - -func newTestServerWithDependencies(client model.Client) (*Server, *tools.Registry, *plugin.Service) { - toolRegistry := tools.NewRegistry() - _ = builtin.RegisterBuiltinTools(toolRegistry) - _ = sidecarclient.RegisterPlaywrightTools(toolRegistry) - _ = sidecarclient.RegisterOCRTools(toolRegistry) - _ = sidecarclient.RegisterMediaTools(toolRegistry) - toolExecutor := tools.NewToolExecutor(toolRegistry) - pathPolicy, _ := platform.NewLocalPathPolicy(filepath.Join("workspace", "rpc-test")) - fileSystem := platform.NewLocalFileSystemAdapter(pathPolicy) - pluginService := plugin.NewService() - executionService := execution.NewService( - fileSystem, - stubExecutionCapability{result: tools.CommandExecutionResult{Stdout: "ok", ExitCode: 0}}, - sidecarclient.NewNoopPlaywrightSidecarClient(), - sidecarclient.NewNoopOCRWorkerClient(), - sidecarclient.NewNoopMediaWorkerClient(), - sidecarclient.NewNoopScreenCaptureClient(), - model.NewService(serviceconfig.ModelConfig{Provider: "openai_responses", ModelID: "gpt-5.4", Endpoint: "https://api.openai.com/v1/responses"}, client), - audit.NewService(), - checkpoint.NewService(), - delivery.NewService(), - toolRegistry, - toolExecutor, - pluginService, - ) - orch := orchestrator.NewService( - contextsvc.NewService(), - intent.NewService(), - runengine.NewEngine(), - delivery.NewService(), - memory.NewService(), - risk.NewService(), - model.NewService(serviceconfig.ModelConfig{ - Provider: "openai_responses", - ModelID: "gpt-5.4", - Endpoint: "https://api.openai.com/v1/responses", - }), - toolRegistry, - pluginService, - ).WithExecutor(executionService) - - server := NewServer(serviceconfig.RPCConfig{ - Transport: "named_pipe", - NamedPipeName: `\\.\pipe\cialloclaw-rpc-test`, - DebugHTTPAddress: ":0", - }, orch) - server.now = func() time.Time { - return time.Date(2026, 4, 8, 10, 0, 0, 0, time.UTC) - } - return server, toolRegistry, pluginService -} - -func mustMarshal(t *testing.T, value any) json.RawMessage { - t.Helper() - encoded, err := json.Marshal(value) - if err != nil { - t.Fatalf("marshal request params: %v", err) - } - return encoded -} - -func numericValue(t *testing.T, value any) int { - t.Helper() - switch typed := value.(type) { - case int: - return typed - case int32: - return int(typed) - case int64: - return int(typed) - case float64: - return int(typed) - default: - t.Fatalf("expected numeric value, got %#v", value) - return 0 - } -} From 1de2feb60f3184f2aaf6b0dd0df84a11221186d5 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 05:19:06 +0000 Subject: [PATCH 13/41] fix(local-service): tighten typed entry dto contracts Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../orchestrator/protocol_dto_test.go | 44 +++++ .../orchestrator/protocol_response_dto.go | 1 - .../local-service/internal/rpc/entry_dto.go | 69 +++++++ .../internal/rpc/protocol_registry_test.go | 187 ++++++++++++++++++ 4 files changed, 300 insertions(+), 1 deletion(-) diff --git a/services/local-service/internal/orchestrator/protocol_dto_test.go b/services/local-service/internal/orchestrator/protocol_dto_test.go index 6cdb1f754..cf5c16d34 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test.go @@ -158,3 +158,47 @@ func TestTaskDetailGetResponseMapNormalizesUnknownFields(t *testing.T) { t.Fatalf("expected typed detail normalization to drop unknown runtime summary fields, got %+v", runtimeSummary) } } + +func TestTaskDetailGetResponseMapDropsAuthorizationRecordRunID(t *testing.T) { + response := newTaskDetailGetResponse(map[string]any{ + "task": map[string]any{ + "task_id": "task_detail_auth", + "session_id": "sess_auth", + "title": "Inspect approval history", + "source_type": "screen_capture", + "status": "completed", + "current_step": "generate_output", + "risk_level": "yellow", + "started_at": "2026-05-10T00:00:00Z", + "updated_at": "2026-05-10T00:00:01Z", + }, + "timeline": []map[string]any{}, + "delivery_result": nil, + "artifacts": []map[string]any{}, + "citations": []map[string]any{}, + "mirror_references": []map[string]any{}, + "approval_request": nil, + "authorization_record": map[string]any{ + "authorization_record_id": "auth_001", + "task_id": "task_detail_auth", + "run_id": "run_hidden", + "approval_id": "appr_001", + "decision": "allow_once", + "remember_rule": false, + "operator": "user", + "created_at": "2026-05-10T00:00:02Z", + }, + "audit_record": nil, + "security_summary": map[string]any{"pending_authorizations": 0, "latest_restore_point": nil}, + "runtime_summary": map[string]any{"events_count": 0, "active_steering_count": 0, "observation_signals": []string{}}, + }) + + mapped := response.Map() + authorizationRecord := mapValue(mapped, "authorization_record") + if _, ok := authorizationRecord["run_id"]; ok { + t.Fatalf("expected authorization_record run_id to stay out of the stable dto, got %+v", authorizationRecord) + } + if stringValue(authorizationRecord, "authorization_record_id", "") != "auth_001" { + t.Fatalf("expected typed detail normalization to preserve declared authorization fields, got %+v", authorizationRecord) + } +} diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index 2f6b8f5d0..f9265534e 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -114,7 +114,6 @@ type ApprovalRequestDTO struct { type AuthorizationRecordDTO struct { AuthorizationRecordID string `json:"authorization_record_id"` TaskID string `json:"task_id"` - RunID string `json:"run_id,omitempty"` ApprovalID string `json:"approval_id"` Decision string `json:"decision"` RememberRule bool `json:"remember_rule"` diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index 7db433166..394202f7a 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -147,6 +147,9 @@ func validateAgentInputSubmitParams(params map[string]any) *rpcError { 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 } @@ -177,6 +180,9 @@ func validateAgentTaskStartParams(params map[string]any) *rpcError { 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 } @@ -200,6 +206,42 @@ func validateAgentTaskDetailGetParams(params map[string]any) *rpcError { return nil } +// 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") && + !hasNonEmptyString(input, "selection_text") { + return invalidParamsError("text_selection task input requires selected text") + } + case "file": + if !hasNonEmptyStringSlice(input, "files") && + !hasNonEmptyStringSlice(context, "files") && + !hasNonEmptyStringSlice(input, "file_paths") && + !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") && + !hasNonEmptyString(context, "error_text") { + return invalidParamsError("error task input requires an error message") + } + } + return nil +} + func validateContextEnvelope(params map[string]any) *rpcError { pageContext := mapObject(mapObject(params, "input"), "page_context") if err := optionalEnumValue(pageContext, "browser_kind", browserKindSet); err != nil { @@ -266,6 +308,33 @@ func requireNonEmptyString(values map[string]any, key string) *rpcError { 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 diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index a6c6547f1..617d5860d 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -343,6 +343,193 @@ func TestAgentTaskStartDTORejectsOutOfDomainPageContextBrowserWithoutContext(t * } } +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 protocolStableMethodBlock(source string) string { start := strings.Index(source, "RPC_METHODS_STABLE") end := strings.Index(source, "RPC_METHODS_PLANNED") From e2b561e6f6b0d93c21fe0cf06f984161fa3b323f Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 05:55:44 +0000 Subject: [PATCH 14/41] fix(rpc): remove unused typed dto test helpers Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/rpc/rpc_test_helpers_test.go | 16 ---------------- 1 file changed, 16 deletions(-) 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 dea72828a..228d8b127 100644 --- a/services/local-service/internal/rpc/rpc_test_helpers_test.go +++ b/services/local-service/internal/rpc/rpc_test_helpers_test.go @@ -222,22 +222,6 @@ func startTaskForTest(s *orchestrator.Service, params map[string]any) (map[strin return response.Map(), nil } -func submitInputForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { - response, err := s.SubmitInput(orchestrator.SubmitInputRequestFromParams(params)) - if err != nil { - return nil, err - } - return response.Map(), nil -} - -func taskDetailGetForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { - response, err := s.TaskDetailGet(orchestrator.TaskDetailGetRequestFromParams(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) From a24afd16ee49e2a61940d220e97ebcfece258981 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 06:17:21 +0000 Subject: [PATCH 15/41] fix(rpc): tighten stable task list and task start params Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../local-service/internal/rpc/entry_dto.go | 59 ++++++++- .../local-service/internal/rpc/methods.go | 2 +- .../internal/rpc/protocol_registry_test.go | 123 ++++++++++++++++++ .../rpc/server_stream_coordination_test.go | 3 + 4 files changed, 181 insertions(+), 6 deletions(-) diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index 394202f7a..9d62a88cd 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -3,6 +3,7 @@ package rpc import ( "bytes" "encoding/json" + "math" "strings" "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" @@ -36,6 +37,19 @@ var ( "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": {}, @@ -80,6 +94,10 @@ func decodeAgentTaskDetailGetParams(raw json.RawMessage) (map[string]any, *rpcEr return decodeTypedProtocolParams(raw, ¶ms, validateAgentTaskDetailGetParams) } +func decodeAgentTaskListParams(raw json.RawMessage) (map[string]any, *rpcError) { + return decodeParamsWithValidation(raw, validateAgentTaskListParams) +} + func decodeTypedProtocolParams(raw json.RawMessage, target any, validate func(map[string]any) *rpcError) (map[string]any, *rpcError) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { @@ -206,6 +224,28 @@ func validateAgentTaskDetailGetParams(params map[string]any) *rpcError { return nil } +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 +} + // 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 @@ -220,22 +260,19 @@ func validateTaskStartInputPayload(input, context map[string]any) *rpcError { selection := mapObject(context, "selection") if !hasNonEmptyString(input, "text") && !hasNonEmptyString(selection, "text") && - !hasNonEmptyString(context, "selection_text") && - !hasNonEmptyString(input, "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(input, "file_paths") && !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") && - !hasNonEmptyString(context, "error_text") { + !hasNonEmptyString(errorContext, "message") { return invalidParamsError("error task input requires an error message") } } @@ -308,6 +345,18 @@ func requireNonEmptyString(values map[string]any, key string) *rpcError { 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 hasNonEmptyString(values map[string]any, key string) bool { raw, ok := values[key] if !ok { diff --git a/services/local-service/internal/rpc/methods.go b/services/local-service/internal/rpc/methods.go index bea4953ce..e9014e719 100644 --- a/services/local-service/internal/rpc/methods.go +++ b/services/local-service/internal/rpc/methods.go @@ -60,7 +60,7 @@ func (s *Server) stableMethodRegistry() []registeredMethod { registered(methodAgentTaskConfirm, decodeParamsRequiringRequestMeta, s.handleAgentTaskConfirm), registered(methodAgentRecommendationGet, decodeParamsRequiringRequestMeta, s.handleAgentRecommendationGet), registered(methodAgentRecommendationFeedbackSubmit, decodeParamsRequiringRequestMeta, s.handleAgentRecommendationFeedbackSubmit), - registered(methodAgentTaskList, decodeParamsRequiringRequestMeta, s.handleAgentTaskList), + registered(methodAgentTaskList, decodeAgentTaskListParams, s.handleAgentTaskList), registered(methodAgentTaskDetailGet, decodeAgentTaskDetailGetParams, s.handleAgentTaskDetailGet), registered(methodAgentTaskEventsList, decodeParamsRequiringRequestMeta, s.handleAgentTaskEventsList), registered(methodAgentTaskToolCallsList, decodeParamsRequiringRequestMeta, s.handleAgentTaskToolCallsList), diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index 617d5860d..d7e62f523 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -93,6 +93,7 @@ func TestStableMethodRegistryDispatchMatrix(t *testing.T) { methodAgentTaskConfirm: decodeParamsRequiringRequestMeta, methodAgentTaskControl: decodeParamsRequiringRequestMeta, methodAgentTaskDetailGet: decodeAgentTaskDetailGetParams, + methodAgentTaskList: decodeAgentTaskListParams, methodAgentTaskInspectorConfigGet: decodeParamsRequiringRequestMeta, methodAgentTaskInspectorRun: decodeParamsRequiringRequestMeta, methodAgentDeliveryOpen: decodeParamsRequiringRequestMeta, @@ -319,6 +320,61 @@ func TestDecodeParamsRequiringRequestMetaMatchesStableContract(t *testing.T) { } } +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{ @@ -530,6 +586,73 @@ func TestAgentTaskStartDTOAcceptsContextBackedTypeSpecificPayload(t *testing.T) } } +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/server_stream_coordination_test.go b/services/local-service/internal/rpc/server_stream_coordination_test.go index 97fc110c1..ce99adc73 100644 --- a/services/local-service/internal/rpc/server_stream_coordination_test.go +++ b/services/local-service/internal/rpc/server_stream_coordination_test.go @@ -384,6 +384,9 @@ func TestHandleStreamConnTaskListDoesNotStealBufferedNotifications(t *testing.T) Method: "agent.task.list", 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) From d7e8cbef6b9db8fe2a420524fd69ee280bd02645 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 08:05:01 +0000 Subject: [PATCH 16/41] fix(context): drop retired task entry aliases Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../local-service/internal/context/service.go | 8 +----- .../internal/context/service_test.go | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/services/local-service/internal/context/service.go b/services/local-service/internal/context/service.go index 50136e0c7..584185607 100644 --- a/services/local-service/internal/context/service.go +++ b/services/local-service/internal/context/service.go @@ -53,9 +53,6 @@ func (s *Service) 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 +63,6 @@ func (s *Service) 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 +71,12 @@ func (s *Service) 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 == "" { diff --git a/services/local-service/internal/context/service_test.go b/services/local-service/internal/context/service_test.go index ea64a311c..3b1852622 100644 --- a/services/local-service/internal/context/service_test.go +++ b/services/local-service/internal/context/service_test.go @@ -157,6 +157,31 @@ func TestServiceSnapshotAndCaptureInferenceHelpers(t *testing.T) { } } +func TestServiceCaptureIgnoresRetiredTaskEntryAliases(t *testing.T) { + service := NewService() + + 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 TestContextPrimitiveHelpersCoverAdditionalBranches(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) From c53e4d2dd22476254c4801ffe55630dd125d0ffc Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 08:43:48 +0000 Subject: [PATCH 17/41] fix(local-service): align typed dto boundaries Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- docs/module-design.md | 2 +- .../orchestrator/protocol_response_dto.go | 554 +++++++++++++++++- .../internal/perception/service.go | 2 +- .../internal/perception/service_test.go | 6 + .../local-service/internal/rpc/entry_dto.go | 3 + 5 files changed, 548 insertions(+), 19 deletions(-) diff --git a/docs/module-design.md b/docs/module-design.md index 54cd54a08..f7fd6ac0f 100644 --- a/docs/module-design.md +++ b/docs/module-design.md @@ -1322,7 +1322,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/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index f9265534e..72d0768b8 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -185,11 +185,19 @@ type TaskDetailGetResponse struct { } // StartTaskRequestFromParams adapts RPC-decoded params to the typed -// orchestrator request. The map is accepted only at the RPC adapter boundary, -// then normalized back through the typed DTO before orchestration continues. +// 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 { - var request StartTaskRequest - decodeProtocolMap(params, &request) + 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) } @@ -197,25 +205,60 @@ func StartTaskRequestFromParams(params map[string]any) StartTaskRequest { } // SubmitInputRequestFromParams adapts RPC-decoded params to the typed -// orchestrator request. The map is accepted only at the RPC adapter boundary, -// then normalized back through the typed DTO before orchestration continues. +// 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 { - var request SubmitInputRequest - decodeProtocolMap(params, &request) - return request + 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 map is accepted only at the RPC adapter boundary, -// then normalized back through the typed DTO before orchestration continues. +// 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 { - var request TaskDetailGetRequest - decodeProtocolMap(params, &request) - return request + return TaskDetailGetRequest{ + RequestMeta: requestMetaFromMap(mapValue(params, "request_meta")), + TaskID: stringValue(params, "task_id", ""), + } } func (r StartTaskRequest) paramsMap() map[string]any { - params := structToProtocolMap(r) + return r.ProtocolParamsMap() +} + +// ProtocolParamsMap exports the normalized protocol payload for RPC adapters +// that have already validated the transport envelope. +func (r StartTaskRequest) ProtocolParamsMap() map[string]any { + params := map[string]any{ + "request_meta": r.RequestMeta.protocolMap(), + "input": r.Input.protocolMap(), + } + if strings.TrimSpace(r.SessionID) != "" { + params["session_id"] = r.SessionID + } + if strings.TrimSpace(r.Source) != "" { + params["source"] = r.Source + } + if strings.TrimSpace(r.Trigger) != "" { + params["trigger"] = r.Trigger + } + if r.Context != nil { + params["context"] = r.Context.protocolMap() + } + if r.Delivery != nil { + params["delivery"] = r.Delivery.protocolMap() + } + if r.Options != nil { + params["options"] = r.Options.protocolMap() + } if len(r.Intent) > 0 { params["intent"] = cloneMap(r.Intent) } @@ -223,11 +266,48 @@ func (r StartTaskRequest) paramsMap() map[string]any { } func (r SubmitInputRequest) paramsMap() map[string]any { - return structToProtocolMap(r) + return r.ProtocolParamsMap() +} + +// ProtocolParamsMap exports the normalized protocol payload for RPC adapters +// that have already validated the transport envelope. +func (r SubmitInputRequest) ProtocolParamsMap() map[string]any { + params := map[string]any{ + "request_meta": r.RequestMeta.protocolMap(), + "input": r.Input.protocolMap(), + } + if strings.TrimSpace(r.SessionID) != "" { + params["session_id"] = r.SessionID + } + if strings.TrimSpace(r.Source) != "" { + params["source"] = r.Source + } + if strings.TrimSpace(r.Trigger) != "" { + params["trigger"] = r.Trigger + } + if r.Context != nil { + params["context"] = r.Context.protocolMap() + } + if r.VoiceMeta != nil { + params["voice_meta"] = r.VoiceMeta.protocolMap() + } + if r.Options != nil { + params["options"] = r.Options.protocolMap() + } + return params } func (r TaskDetailGetRequest) paramsMap() map[string]any { - return structToProtocolMap(r) + return r.ProtocolParamsMap() +} + +// ProtocolParamsMap exports the normalized protocol payload for RPC adapters +// that have already validated the transport envelope. +func (r TaskDetailGetRequest) ProtocolParamsMap() map[string]any { + return map[string]any{ + "request_meta": r.RequestMeta.protocolMap(), + "task_id": r.TaskID, + } } func newTaskEntryResponse(payload map[string]any) TaskEntryResponse { @@ -277,6 +357,446 @@ func structToProtocolMap(value any) map[string]any { return result } +func requestMetaFromMap(values map[string]any) RequestMeta { + return RequestMeta{ + TraceID: stringValue(values, "trace_id", ""), + ClientTime: stringValue(values, "client_time", ""), + } +} + +func (m RequestMeta) protocolMap() map[string]any { + return map[string]any{ + "trace_id": m.TraceID, + "client_time": m.ClientTime, + } +} + +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 (c *PageContext) protocolMap() map[string]any { + if c == nil { + return nil + } + params := map[string]any{} + if c.Title != "" { + params["title"] = c.Title + } + if c.AppName != "" { + params["app_name"] = c.AppName + } + if c.URL != "" { + params["url"] = c.URL + } + if c.BrowserKind != "" { + params["browser_kind"] = c.BrowserKind + } + if c.ProcessPath != "" { + params["process_path"] = c.ProcessPath + } + if c.ProcessID != 0 { + params["process_id"] = c.ProcessID + } + if c.WindowTitle != "" { + params["window_title"] = c.WindowTitle + } + if c.VisibleText != "" { + params["visible_text"] = c.VisibleText + } + if c.HoverTarget != "" { + params["hover_target"] = c.HoverTarget + } + return params +} + +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 (c *ScreenContext) protocolMap() map[string]any { + if c == nil { + return nil + } + params := map[string]any{} + if c.Summary != "" { + params["summary"] = c.Summary + } + if c.ScreenSummary != "" { + params["screen_summary"] = c.ScreenSummary + } + if c.VisibleText != "" { + params["visible_text"] = c.VisibleText + } + if c.WindowTitle != "" { + params["window_title"] = c.WindowTitle + } + if c.HoverTarget != "" { + params["hover_target"] = c.HoverTarget + } + return params +} + +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 (c *BehaviorContext) protocolMap() map[string]any { + if c == nil { + return nil + } + params := map[string]any{} + if c.LastAction != "" { + params["last_action"] = c.LastAction + } + if c.DwellMillis != 0 { + params["dwell_millis"] = c.DwellMillis + } + if c.CopyCount != 0 { + params["copy_count"] = c.CopyCount + } + if c.WindowSwitchCount != 0 { + params["window_switch_count"] = c.WindowSwitchCount + } + if c.PageSwitchCount != 0 { + params["page_switch_count"] = c.PageSwitchCount + } + return params +} + +func selectionContextPointerFromMap(values map[string]any) *SelectionContext { + if len(values) == 0 { + return nil + } + return &SelectionContext{Text: stringValue(values, "text", "")} +} + +func (c *SelectionContext) protocolMap() map[string]any { + if c == nil || c.Text == "" { + return nil + } + return map[string]any{"text": c.Text} +} + +func errorContextPointerFromMap(values map[string]any) *ErrorContext { + if len(values) == 0 { + return nil + } + return &ErrorContext{Message: stringValue(values, "message", "")} +} + +func (c *ErrorContext) protocolMap() map[string]any { + if c == nil || c.Message == "" { + return nil + } + return map[string]any{"message": c.Message} +} + +func clipboardContextPointerFromMap(values map[string]any) *ClipboardContext { + if len(values) == 0 { + return nil + } + return &ClipboardContext{Text: stringValue(values, "text", "")} +} + +func (c *ClipboardContext) protocolMap() map[string]any { + if c == nil || c.Text == "" { + return nil + } + return map[string]any{"text": c.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 (c *InputContext) protocolMap() map[string]any { + if c == nil { + return nil + } + params := map[string]any{} + if page := c.Page.protocolMap(); len(page) > 0 { + params["page"] = page + } + if screen := c.Screen.protocolMap(); len(screen) > 0 { + params["screen"] = screen + } + if behavior := c.Behavior.protocolMap(); len(behavior) > 0 { + params["behavior"] = behavior + } + if selection := c.Selection.protocolMap(); len(selection) > 0 { + params["selection"] = selection + } + if errValue := c.Error.protocolMap(); len(errValue) > 0 { + params["error"] = errValue + } + if clipboard := c.Clipboard.protocolMap(); len(clipboard) > 0 { + params["clipboard"] = clipboard + } + if c.Text != "" { + params["text"] = c.Text + } + if c.SelectionText != "" { + params["selection_text"] = c.SelectionText + } + if len(c.Files) > 0 { + params["files"] = append([]string(nil), c.Files...) + } + if len(c.FilePaths) > 0 { + params["file_paths"] = append([]string(nil), c.FilePaths...) + } + if c.ScreenSummary != "" { + params["screen_summary"] = c.ScreenSummary + } + if c.ClipboardText != "" { + params["clipboard_text"] = c.ClipboardText + } + if c.HoverTarget != "" { + params["hover_target"] = c.HoverTarget + } + if c.LastAction != "" { + params["last_action"] = c.LastAction + } + if c.DwellMillis != 0 { + params["dwell_millis"] = c.DwellMillis + } + if c.CopyCount != 0 { + params["copy_count"] = c.CopyCount + } + if c.WindowSwitchCount != 0 { + params["window_switch_count"] = c.WindowSwitchCount + } + if c.PageSwitchCount != 0 { + params["page_switch_count"] = c.PageSwitchCount + } + return params +} + +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 (m *VoiceMeta) protocolMap() map[string]any { + if m == nil { + return nil + } + params := map[string]any{} + if m.VoiceSessionID != "" { + params["voice_session_id"] = m.VoiceSessionID + } + if m.IsLockedSession { + params["is_locked_session"] = true + } + if m.ASRConfidence != 0 { + params["asr_confidence"] = m.ASRConfidence + } + if m.SegmentID != "" { + params["segment_id"] = m.SegmentID + } + return params +} + +func inputSubmitInputFromMap(values map[string]any) InputSubmitInput { + return InputSubmitInput{ + Type: stringValue(values, "type", ""), + Text: stringValue(values, "text", ""), + InputMode: stringValue(values, "input_mode", ""), + } +} + +func (i InputSubmitInput) protocolMap() map[string]any { + params := map[string]any{} + if i.Type != "" { + params["type"] = i.Type + } + if i.Text != "" { + params["text"] = i.Text + } + if i.InputMode != "" { + params["input_mode"] = i.InputMode + } + return params +} + +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 (o *InputSubmitOptions) protocolMap() map[string]any { + if o == nil { + return nil + } + params := map[string]any{} + if o.ConfirmRequired { + params["confirm_required"] = true + } + if o.PreferredDelivery != "" { + params["preferred_delivery"] = o.PreferredDelivery + } + return params +} + +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 (i TaskStartInput) protocolMap() map[string]any { + params := map[string]any{} + if i.Type != "" { + params["type"] = i.Type + } + if i.Text != "" { + params["text"] = i.Text + } + if len(i.Files) > 0 { + params["files"] = append([]string(nil), i.Files...) + } + if pageContext := i.PageContext.protocolMap(); len(pageContext) > 0 { + params["page_context"] = pageContext + } + if i.ErrorMessage != "" { + params["error_message"] = i.ErrorMessage + } + return params +} + +func deliveryPreferencePointerFromMap(values map[string]any) *DeliveryPreference { + if len(values) == 0 { + return nil + } + return &DeliveryPreference{ + Preferred: stringValue(values, "preferred", ""), + Fallback: stringValue(values, "fallback", ""), + } +} + +func (p *DeliveryPreference) protocolMap() map[string]any { + if p == nil { + return nil + } + params := map[string]any{} + if p.Preferred != "" { + params["preferred"] = p.Preferred + } + if p.Fallback != "" { + params["fallback"] = p.Fallback + } + return params +} + +func taskStartOptionsPointerFromMap(values map[string]any) *TaskStartOptions { + if len(values) == 0 { + return nil + } + return &TaskStartOptions{ + ConfirmRequired: boolValue(values, "confirm_required", false), + } +} + +func (o *TaskStartOptions) protocolMap() map[string]any { + if o == nil { + return nil + } + if !o.ConfirmRequired { + return map[string]any{} + } + return map[string]any{"confirm_required": true} +} + +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 responseDTOToProtocolMap(value any) map[string]any { result, ok := protocolValueFromReflect(reflect.ValueOf(value)).(map[string]any) if !ok || result == nil { diff --git a/services/local-service/internal/perception/service.go b/services/local-service/internal/perception/service.go index 490febc57..6b479aa11 100644 --- a/services/local-service/internal/perception/service.go +++ b/services/local-service/internal/perception/service.go @@ -76,7 +76,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..4899577bd 100644 --- a/services/local-service/internal/perception/service_test.go +++ b/services/local-service/internal/perception/service_test.go @@ -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,9 @@ 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 TestBehaviorSignalsAndOpportunitiesReflectPerceptionContext(t *testing.T) { diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index 9d62a88cd..9e495b1fa 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -125,6 +125,9 @@ func decodeTypedProtocolParams(raw json.RawMessage, target any, validate func(ma TraceID: "trace_rpc_params", } } + if normalized, ok := target.(interface{ ProtocolParamsMap() map[string]any }); ok { + return normalized.ProtocolParamsMap(), nil + } return protocolParamsMap(target) } From ea8732e6436a84d40bc7cffabc05c23ffe490de5 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 09:39:22 +0000 Subject: [PATCH 18/41] fix(rpc): accept typed dto normalized values Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../local-service/internal/rpc/jsonrpc.go | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/services/local-service/internal/rpc/jsonrpc.go b/services/local-service/internal/rpc/jsonrpc.go index 3fa6f7a16..e83a578ad 100644 --- a/services/local-service/internal/rpc/jsonrpc.go +++ b/services/local-service/internal/rpc/jsonrpc.go @@ -228,36 +228,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 } From 1663b6048b907e79a97cdd1741581cb8813ccce5 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 10:00:48 +0000 Subject: [PATCH 19/41] docs(protocol): align task start typed context examples Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- docs/protocol-design.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/protocol-design.md b/docs/protocol-design.md index 2bfafaa0a..b2dabd6e1 100644 --- a/docs/protocol-design.md +++ b/docs/protocol-design.md @@ -809,7 +809,10 @@ Notification 只负责“状态变化推送”,不承载复杂业务命令。 | `input.page_context.window_title` | 当前窗口标题 | | `input.page_context.visible_text` | 当前页面可见文本摘录 | | `context.selection.text` | 当前选区补充文本,按需传入 | +| `context.selection_text` | 当前选区的平铺补充文本,按需传入 | | `context.files` | 补充文件上下文,按需传入 | +| `context.file_paths` | 兼容文件路径列表,按需传入 | +| `context.error.message` | 当前错误文本,按需传入 | | `context.screen.summary` | 当前屏幕摘要,可用于视觉型任务上下文 | | `context.screen.visible_text` | 当前屏幕可见文本摘录 | | `context.behavior.last_action` | 最近行为信号,例如 `copy` | @@ -855,14 +858,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": { From 16cbfdb0a88ce4b8e8fd6ddba336584b97effd8b Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sun, 10 May 2026 21:57:31 +0800 Subject: [PATCH 20/41] fix(orchestrator): build response dtos directly --- .../internal/orchestrator/input_submit.go | 2 +- .../orchestrator/protocol_dto_test.go | 46 +- .../orchestrator/protocol_response_dto.go | 967 +++++++++++++++++- .../internal/orchestrator/screen_analyze.go | 2 +- .../orchestrator/screen_approval_state.go | 16 +- .../internal/orchestrator/task_query.go | 2 +- .../internal/orchestrator/task_start.go | 2 +- 7 files changed, 1003 insertions(+), 34 deletions(-) diff --git a/services/local-service/internal/orchestrator/input_submit.go b/services/local-service/internal/orchestrator/input_submit.go index 3151bf8b5..e45015c6e 100644 --- a/services/local-service/internal/orchestrator/input_submit.go +++ b/services/local-service/internal/orchestrator/input_submit.go @@ -16,7 +16,7 @@ func (s *Service) SubmitInput(request SubmitInputRequest) (TaskEntryResponse, er if err != nil { return TaskEntryResponse{}, err } - return newTaskEntryResponse(response), nil + return newTaskEntryResponse(response) } func (s *Service) submitInput(params map[string]any) (map[string]any, error) { diff --git a/services/local-service/internal/orchestrator/protocol_dto_test.go b/services/local-service/internal/orchestrator/protocol_dto_test.go index cf5c16d34..7fd91083e 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test.go @@ -51,7 +51,7 @@ func TestStartTaskRequestFromParamsNormalizesUnknownFields(t *testing.T) { } func TestTaskEntryResponseMapNormalizesUnknownFields(t *testing.T) { - response := newTaskEntryResponse(map[string]any{ + response, err := newTaskEntryResponse(map[string]any{ "task": map[string]any{ "task_id": "task_123", "session_id": "sess_123", @@ -79,6 +79,9 @@ func TestTaskEntryResponseMapNormalizesUnknownFields(t *testing.T) { "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 { @@ -98,7 +101,7 @@ func TestTaskEntryResponseMapNormalizesUnknownFields(t *testing.T) { } func TestTaskDetailGetResponseMapNormalizesUnknownFields(t *testing.T) { - response := newTaskDetailGetResponse(map[string]any{ + response, err := newTaskDetailGetResponse(map[string]any{ "task": map[string]any{ "task_id": "task_detail_123", "session_id": "sess_123", @@ -136,6 +139,9 @@ func TestTaskDetailGetResponseMapNormalizesUnknownFields(t *testing.T) { "runtime_summary": map[string]any{"loop_stop_reason": nil, "events_count": 0, "latest_event_type": nil, "active_steering_count": 0, "latest_failure_code": nil, "latest_failure_category": nil, "latest_failure_summary": nil, "observation_signals": []string{}, "unknown": "drop-me"}, "unknown": "drop-me", }) + if err != nil { + t.Fatalf("build task detail response failed: %v", err) + } mapped := response.Map() if _, ok := mapped["unknown"]; ok { @@ -160,7 +166,7 @@ func TestTaskDetailGetResponseMapNormalizesUnknownFields(t *testing.T) { } func TestTaskDetailGetResponseMapDropsAuthorizationRecordRunID(t *testing.T) { - response := newTaskDetailGetResponse(map[string]any{ + response, err := newTaskDetailGetResponse(map[string]any{ "task": map[string]any{ "task_id": "task_detail_auth", "session_id": "sess_auth", @@ -192,6 +198,9 @@ func TestTaskDetailGetResponseMapDropsAuthorizationRecordRunID(t *testing.T) { "security_summary": map[string]any{"pending_authorizations": 0, "latest_restore_point": nil}, "runtime_summary": map[string]any{"events_count": 0, "active_steering_count": 0, "observation_signals": []string{}}, }) + if err != nil { + t.Fatalf("build task detail response failed: %v", err) + } mapped := response.Map() authorizationRecord := mapValue(mapped, "authorization_record") @@ -202,3 +211,34 @@ func TestTaskDetailGetResponseMapDropsAuthorizationRecordRunID(t *testing.T) { t.Fatalf("expected typed detail normalization to preserve declared authorization fields, got %+v", authorizationRecord) } } + +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 TestTaskDetailGetResponseRejectsMalformedDeclaredObjects(t *testing.T) { + _, err := newTaskDetailGetResponse(map[string]any{"task": "not-an-object"}) + if err == nil { + t.Fatal("expected malformed declared task object to fail response dto construction") + } +} diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index 72d0768b8..e0d32bd51 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -1,7 +1,7 @@ package orchestrator import ( - "encoding/json" + "fmt" "reflect" "strings" ) @@ -310,16 +310,99 @@ func (r TaskDetailGetRequest) ProtocolParamsMap() map[string]any { } } -func newTaskEntryResponse(payload map[string]any) TaskEntryResponse { - var response TaskEntryResponse - decodeProtocolMap(payload, &response) - return response +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 } -func newTaskDetailGetResponse(payload map[string]any) TaskDetailGetResponse { - var response TaskDetailGetResponse - decodeProtocolMap(payload, &response) - return response +func newTaskDetailGetResponse(payload map[string]any) (TaskDetailGetResponse, error) { + taskPayload, ok, err := protocolMapField(payload, "task") + if err != nil { + return TaskDetailGetResponse{}, err + } + if !ok { + return TaskDetailGetResponse{}, fmt.Errorf("task must be object") + } + task, err := taskDTOFromMap(taskPayload) + if err != nil { + return TaskDetailGetResponse{}, fmt.Errorf("task: %w", err) + } + timeline, err := taskStepDTOListFromMap(payload, "timeline") + if err != nil { + return TaskDetailGetResponse{}, err + } + deliveryResult, err := deliveryResultDTOPointerFromMap(payload, "delivery_result") + if err != nil { + return TaskDetailGetResponse{}, err + } + artifacts, err := artifactDTOListFromMap(payload, "artifacts") + if err != nil { + return TaskDetailGetResponse{}, err + } + citations, err := citationDTOListFromMap(payload, "citations") + if err != nil { + return TaskDetailGetResponse{}, err + } + mirrorReferences, err := mirrorReferenceDTOListFromMap(payload, "mirror_references") + if err != nil { + return TaskDetailGetResponse{}, err + } + approvalRequest, err := approvalRequestDTOPointerFromMap(payload, "approval_request") + if err != nil { + return TaskDetailGetResponse{}, err + } + authorizationRecord, err := authorizationRecordDTOPointerFromMap(payload, "authorization_record") + if err != nil { + return TaskDetailGetResponse{}, err + } + auditRecord, err := auditRecordDTOPointerFromMap(payload, "audit_record") + if err != nil { + return TaskDetailGetResponse{}, err + } + securitySummaryPayload, _, err := protocolMapField(payload, "security_summary") + if err != nil { + return TaskDetailGetResponse{}, err + } + securitySummary, err := securitySummaryDTOFromMap(securitySummaryPayload) + if err != nil { + return TaskDetailGetResponse{}, fmt.Errorf("security_summary: %w", err) + } + runtimeSummaryPayload, _, err := protocolMapField(payload, "runtime_summary") + if err != nil { + return TaskDetailGetResponse{}, err + } + runtimeSummary, err := runtimeSummaryDTOFromMap(runtimeSummaryPayload) + if err != nil { + return TaskDetailGetResponse{}, fmt.Errorf("runtime_summary: %w", err) + } + return TaskDetailGetResponse{ + Task: task, + Timeline: timeline, + DeliveryResult: deliveryResult, + Artifacts: artifacts, + Citations: citations, + MirrorReferences: mirrorReferences, + ApprovalRequest: approvalRequest, + AuthorizationRecord: authorizationRecord, + AuditRecord: auditRecord, + SecuritySummary: securitySummary, + RuntimeSummary: runtimeSummary, + }, nil } // Map returns the protocol payload as a map for package tests that assert @@ -334,27 +417,869 @@ func (r TaskDetailGetResponse) Map() map[string]any { return responseDTOToProtocolMap(r) } -func decodeProtocolMap(values map[string]any, target any) { - if len(values) == 0 { - return +func taskDTOPointerFromMap(values map[string]any, key string) (*TaskDTO, error) { + payload, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err } - payload, err := json.Marshal(values) + if !ok { + return nil, nil + } + task, err := taskDTOFromMap(payload) if err != nil { - return + return nil, fmt.Errorf("%s: %w", key, err) } - _ = json.Unmarshal(payload, target) + return &task, nil } -func structToProtocolMap(value any) map[string]any { - payload, err := json.Marshal(value) +func taskDTOFromMap(values map[string]any) (TaskDTO, error) { + taskID, err := protocolStringField(values, "task_id") if err != nil { - return map[string]any{} + return TaskDTO{}, err + } + sessionID, err := protocolStringPointerField(values, "session_id") + if err != nil { + return TaskDTO{}, err + } + title, err := protocolStringField(values, "title") + if err != nil { + return TaskDTO{}, err + } + sourceType, err := protocolStringField(values, "source_type") + if err != nil { + return TaskDTO{}, err + } + status, err := protocolStringField(values, "status") + if err != nil { + return TaskDTO{}, err + } + intent, err := intentPayloadPointerFromMap(values, "intent") + if err != nil { + return TaskDTO{}, err + } + currentStep, err := protocolStringField(values, "current_step") + if err != nil { + return TaskDTO{}, err + } + riskLevel, err := protocolStringField(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 := protocolStringField(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 = nil + } 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 := protocolStringField(values, "bubble_id") + if err != nil { + return BubbleMessageDTO{}, err + } + taskID, err := protocolStringField(values, "task_id") + if err != nil { + return BubbleMessageDTO{}, err + } + messageType, err := protocolStringField(values, "type") + if err != nil { + return BubbleMessageDTO{}, err + } + text, err := protocolStringField(values, "text") + if err != nil { + return BubbleMessageDTO{}, err + } + pinned, err := protocolBoolField(values, "pinned") + if err != nil { + return BubbleMessageDTO{}, err + } + hidden, err := protocolBoolField(values, "hidden") + if err != nil { + return BubbleMessageDTO{}, err + } + createdAt, err := protocolStringField(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 := protocolStringField(values, "type") + if err != nil { + return DeliveryResultDTO{}, err + } + title, err := protocolStringField(values, "title") + if err != nil { + return DeliveryResultDTO{}, err + } + payloadMap, _, err := protocolMapField(values, "payload") + if err != nil { + return DeliveryResultDTO{}, err + } + payload, err := deliveryPayloadDTOFromMap(payloadMap) + if err != nil { + return DeliveryResultDTO{}, fmt.Errorf("payload: %w", err) + } + previewText, err := protocolStringField(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 taskStepDTOListFromMap(values map[string]any, key string) ([]TaskStepDTO, error) { + items, err := protocolMapSliceField(values, key) + if err != nil { + return nil, err + } + result := make([]TaskStepDTO, 0, len(items)) + for index, item := range items { + dto, err := taskStepDTOFromMap(item) + if err != nil { + return nil, fmt.Errorf("%s[%d]: %w", key, index, err) + } + result = append(result, dto) + } + return result, nil +} + +func taskStepDTOFromMap(values map[string]any) (TaskStepDTO, error) { + stepID, err := protocolStringField(values, "step_id") + if err != nil { + return TaskStepDTO{}, err + } + taskID, err := protocolStringField(values, "task_id") + if err != nil { + return TaskStepDTO{}, err + } + name, err := protocolStringField(values, "name") + if err != nil { + return TaskStepDTO{}, err + } + status, err := protocolStringField(values, "status") + if err != nil { + return TaskStepDTO{}, err + } + orderIndex, err := protocolIntField(values, "order_index") + if err != nil { + return TaskStepDTO{}, err + } + inputSummary, err := protocolStringField(values, "input_summary") + if err != nil { + return TaskStepDTO{}, err + } + outputSummary, err := protocolStringField(values, "output_summary") + if err != nil { + return TaskStepDTO{}, err + } + return TaskStepDTO{ + StepID: stepID, + TaskID: taskID, + Name: name, + Status: status, + OrderIndex: orderIndex, + InputSummary: inputSummary, + OutputSummary: outputSummary, + }, nil +} + +func artifactDTOListFromMap(values map[string]any, key string) ([]ArtifactDTO, error) { + items, err := protocolMapSliceField(values, key) + if err != nil { + return nil, err + } + result := make([]ArtifactDTO, 0, len(items)) + for index, item := range items { + dto, err := artifactDTOFromMap(item) + if err != nil { + return nil, fmt.Errorf("%s[%d]: %w", key, index, err) + } + result = append(result, dto) + } + return result, nil +} + +func artifactDTOFromMap(values map[string]any) (ArtifactDTO, error) { + artifactID, err := protocolStringField(values, "artifact_id") + if err != nil { + return ArtifactDTO{}, err + } + taskID, err := protocolStringField(values, "task_id") + if err != nil { + return ArtifactDTO{}, err + } + artifactType, err := protocolStringField(values, "artifact_type") + if err != nil { + return ArtifactDTO{}, err + } + title, err := protocolStringField(values, "title") + if err != nil { + return ArtifactDTO{}, err + } + path, err := protocolStringField(values, "path") + if err != nil { + return ArtifactDTO{}, err + } + mimeType, err := protocolStringField(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 citationDTOListFromMap(values map[string]any, key string) ([]CitationDTO, error) { + items, err := protocolMapSliceField(values, key) + if err != nil { + return nil, err + } + result := make([]CitationDTO, 0, len(items)) + for index, item := range items { + dto, err := citationDTOFromMap(item) + if err != nil { + return nil, fmt.Errorf("%s[%d]: %w", key, index, err) + } + result = append(result, dto) + } + return result, nil +} + +func citationDTOFromMap(values map[string]any) (CitationDTO, error) { + citationID, err := protocolStringField(values, "citation_id") + if err != nil { + return CitationDTO{}, err + } + taskID, err := protocolStringField(values, "task_id") + if err != nil { + return CitationDTO{}, err + } + runID, err := protocolStringField(values, "run_id") + if err != nil { + return CitationDTO{}, err + } + sourceType, err := protocolStringField(values, "source_type") + if err != nil { + return CitationDTO{}, err + } + sourceRef, err := protocolStringField(values, "source_ref") + if err != nil { + return CitationDTO{}, err + } + label, err := protocolStringField(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 mirrorReferenceDTOListFromMap(values map[string]any, key string) ([]MirrorReferenceDTO, error) { + items, err := protocolMapSliceField(values, key) + if err != nil { + return nil, err + } + result := make([]MirrorReferenceDTO, 0, len(items)) + for index, item := range items { + dto, err := mirrorReferenceDTOFromMap(item) + if err != nil { + return nil, fmt.Errorf("%s[%d]: %w", key, index, err) + } + result = append(result, dto) + } + return result, nil +} + +func mirrorReferenceDTOFromMap(values map[string]any) (MirrorReferenceDTO, error) { + memoryID, err := protocolStringField(values, "memory_id") + if err != nil { + return MirrorReferenceDTO{}, err + } + reason, err := protocolStringField(values, "reason") + if err != nil { + return MirrorReferenceDTO{}, err + } + summary, err := protocolStringField(values, "summary") + if err != nil { + return MirrorReferenceDTO{}, err + } + return MirrorReferenceDTO{MemoryID: memoryID, Reason: reason, Summary: summary}, nil +} + +func approvalRequestDTOPointerFromMap(values map[string]any, key string) (*ApprovalRequestDTO, error) { + payload, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + approvalRequest, err := approvalRequestDTOFromMap(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + return &approvalRequest, nil +} + +func approvalRequestDTOFromMap(values map[string]any) (ApprovalRequestDTO, error) { + approvalID, err := protocolStringField(values, "approval_id") + if err != nil { + return ApprovalRequestDTO{}, err + } + taskID, err := protocolStringField(values, "task_id") + if err != nil { + return ApprovalRequestDTO{}, err + } + operationName, err := protocolStringField(values, "operation_name") + if err != nil { + return ApprovalRequestDTO{}, err + } + riskLevel, err := protocolStringField(values, "risk_level") + if err != nil { + return ApprovalRequestDTO{}, err + } + targetObject, err := protocolStringField(values, "target_object") + if err != nil { + return ApprovalRequestDTO{}, err + } + reason, err := protocolStringField(values, "reason") + if err != nil { + return ApprovalRequestDTO{}, err + } + status, err := protocolStringField(values, "status") + if err != nil { + return ApprovalRequestDTO{}, err + } + createdAt, err := protocolStringField(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 authorizationRecordDTOPointerFromMap(values map[string]any, key string) (*AuthorizationRecordDTO, error) { + payload, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + authorizationRecord, err := authorizationRecordDTOFromMap(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + return &authorizationRecord, nil +} + +func authorizationRecordDTOFromMap(values map[string]any) (AuthorizationRecordDTO, error) { + recordID, err := protocolStringField(values, "authorization_record_id") + if err != nil { + return AuthorizationRecordDTO{}, err + } + taskID, err := protocolStringField(values, "task_id") + if err != nil { + return AuthorizationRecordDTO{}, err + } + approvalID, err := protocolStringField(values, "approval_id") + if err != nil { + return AuthorizationRecordDTO{}, err + } + decision, err := protocolStringField(values, "decision") + if err != nil { + return AuthorizationRecordDTO{}, err + } + rememberRule, err := protocolBoolField(values, "remember_rule") + if err != nil { + return AuthorizationRecordDTO{}, err + } + operator, err := protocolStringField(values, "operator") + if err != nil { + return AuthorizationRecordDTO{}, err + } + createdAt, err := protocolStringField(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 auditRecordDTOPointerFromMap(values map[string]any, key string) (*AuditRecordDTO, error) { + payload, ok, err := protocolMapField(values, key) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + auditRecord, err := auditRecordDTOFromMap(payload) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + return &auditRecord, nil +} + +func auditRecordDTOFromMap(values map[string]any) (AuditRecordDTO, error) { + auditID, err := protocolStringField(values, "audit_id") + if err != nil { + return AuditRecordDTO{}, err + } + taskID, err := protocolStringField(values, "task_id") + if err != nil { + return AuditRecordDTO{}, err + } + recordType, err := protocolStringField(values, "type") + if err != nil { + return AuditRecordDTO{}, err + } + action, err := protocolStringField(values, "action") + if err != nil { + return AuditRecordDTO{}, err + } + summary, err := protocolStringField(values, "summary") + if err != nil { + return AuditRecordDTO{}, err + } + target, err := protocolStringField(values, "target") + if err != nil { + return AuditRecordDTO{}, err + } + result, err := protocolStringField(values, "result") + if err != nil { + return AuditRecordDTO{}, err + } + createdAt, err := protocolStringField(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 := protocolStringField(values, "recovery_point_id") + if err != nil { + return RecoveryPointDTO{}, err + } + taskID, err := protocolStringField(values, "task_id") + if err != nil { + return RecoveryPointDTO{}, err + } + summary, err := protocolStringField(values, "summary") + if err != nil { + return RecoveryPointDTO{}, err + } + createdAt, err := protocolStringField(values, "created_at") + if err != nil { + return RecoveryPointDTO{}, err + } + objects, err := protocolStringSliceField(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 := protocolStringField(values, "security_status") + if err != nil { + return SecuritySummaryDTO{}, err + } + riskLevel, err := protocolStringField(values, "risk_level") + if err != nil { + return SecuritySummaryDTO{}, err + } + pendingAuthorizations, err := protocolIntField(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 := protocolIntField(values, "events_count") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + latestEventType, err := protocolStringPointerField(values, "latest_event_type") + if err != nil { + return TaskRuntimeSummaryDTO{}, err + } + activeSteeringCount, err := protocolIntField(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 := protocolStringSliceField(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 protocolMapSliceField(values map[string]any, key string) ([]map[string]any, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return nil, nil + } + switch value := rawValue.(type) { + case []map[string]any: + result := make([]map[string]any, 0, len(value)) + for _, item := range value { + result = append(result, cloneProtocolMap(item)) + } + return result, nil + case []any: + result := make([]map[string]any, 0, len(value)) + for index, rawItem := range value { + item, ok := rawItem.(map[string]any) + if !ok { + return nil, protocolIndexedTypeError(key, index, "object", rawItem) + } + result = append(result, cloneProtocolMap(item)) + } + return result, nil + default: + return nil, protocolTypeError(key, "array of objects", rawValue) + } +} + +func protocolStringField(values map[string]any, key string) (string, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return "", nil } - var result map[string]any - if err := json.Unmarshal(payload, &result); err != nil { + 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 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 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 cloneProtocolMap(values map[string]any) map[string]any { + if values == nil { + return nil + } + cloned := cloneMap(values) + if cloned == nil { return map[string]any{} } - return result + 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 { diff --git a/services/local-service/internal/orchestrator/screen_analyze.go b/services/local-service/internal/orchestrator/screen_analyze.go index 01d9d1361..3387e3db1 100644 --- a/services/local-service/internal/orchestrator/screen_analyze.go +++ b/services/local-service/internal/orchestrator/screen_analyze.go @@ -138,7 +138,7 @@ func (s *Service) buildScreenAnalysisApprovalState(task runengine.TaskRecord) (s }, } bubble := s.delivery.BuildBubbleMessage(task.TaskID, "status", presentation.Text(presentation.MessageBubbleScreenApproval, nil), task.UpdatedAt.Format(dateTimeLayout)) - return newScreenAnalysisApprovalState(approvalRequest, pendingExecution, bubble), nil + return newScreenAnalysisApprovalState(approvalRequest, pendingExecution, bubble) } func (s *Service) resolveScreenAnalyzeIntent(snapshot taskcontext.TaskContextSnapshot, current map[string]any) map[string]any { diff --git a/services/local-service/internal/orchestrator/screen_approval_state.go b/services/local-service/internal/orchestrator/screen_approval_state.go index 6bf7173e5..6677ae301 100644 --- a/services/local-service/internal/orchestrator/screen_approval_state.go +++ b/services/local-service/internal/orchestrator/screen_approval_state.go @@ -31,16 +31,20 @@ type screenAnalysisImpactScope struct { OverwriteOrDeleteRisk bool `json:"overwrite_or_delete_risk"` } -func newScreenAnalysisApprovalState(approvalRequest map[string]any, pendingExecution screenAnalysisPendingExecution, bubble map[string]any) screenAnalysisApprovalState { - var approval ApprovalRequestDTO - decodeProtocolMap(approvalRequest, &approval) - var bubbleMessage BubbleMessageDTO - decodeProtocolMap(bubble, &bubbleMessage) +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 { diff --git a/services/local-service/internal/orchestrator/task_query.go b/services/local-service/internal/orchestrator/task_query.go index 2258247b8..250528310 100644 --- a/services/local-service/internal/orchestrator/task_query.go +++ b/services/local-service/internal/orchestrator/task_query.go @@ -43,7 +43,7 @@ func (s *Service) TaskDetailGet(request TaskDetailGetRequest) (TaskDetailGetResp if err != nil { return TaskDetailGetResponse{}, err } - return newTaskDetailGetResponse(response), nil + return newTaskDetailGetResponse(response) } func (s *Service) taskDetailGet(params map[string]any) (map[string]any, error) { diff --git a/services/local-service/internal/orchestrator/task_start.go b/services/local-service/internal/orchestrator/task_start.go index 521129882..6a1a964af 100644 --- a/services/local-service/internal/orchestrator/task_start.go +++ b/services/local-service/internal/orchestrator/task_start.go @@ -18,7 +18,7 @@ func (s *Service) StartTask(request StartTaskRequest) (TaskEntryResponse, error) if err != nil { return TaskEntryResponse{}, err } - return newTaskEntryResponse(response), nil + return newTaskEntryResponse(response) } func (s *Service) startTask(params map[string]any) (map[string]any, error) { From db0afd4f528bbb3dbe3eadd3cf130f424ccd00d2 Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sun, 10 May 2026 21:57:39 +0800 Subject: [PATCH 21/41] test(orchestrator): use typed task detail helper --- .../local-service/internal/orchestrator/service_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/local-service/internal/orchestrator/service_test.go b/services/local-service/internal/orchestrator/service_test.go index 3d22ff77c..664b341a9 100644 --- a/services/local-service/internal/orchestrator/service_test.go +++ b/services/local-service/internal/orchestrator/service_test.go @@ -13264,7 +13264,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13343,7 +13343,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13411,7 +13411,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, map[string]any{"task_id": taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } From b85acbd28587c0002b1be23d10ae378b7ac239ea Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Sun, 10 May 2026 21:57:49 +0800 Subject: [PATCH 22/41] test(rpc): include metadata in delivery open dispatch --- services/local-service/internal/rpc/dispatch_storage_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/local-service/internal/rpc/dispatch_storage_test.go b/services/local-service/internal/rpc/dispatch_storage_test.go index 5aa5c6c50..f653a7b00 100644 --- a/services/local-service/internal/rpc/dispatch_storage_test.go +++ b/services/local-service/internal/rpc/dispatch_storage_test.go @@ -333,7 +333,7 @@ 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 { From c75854481173a99a4974457c504d898c355c12b6 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 14:51:29 +0000 Subject: [PATCH 23/41] fix(local-service): tighten typed dto boundaries Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/orchestrator/input_submit.go | 8 +- .../orchestrator/protocol_dto_test.go | 177 +++++++++++++++++- .../protocol_dto_test_helpers_test.go | 6 +- .../orchestrator/protocol_response_dto.go | 62 ++++-- .../internal/orchestrator/task_query.go | 21 ++- .../internal/orchestrator/task_start.go | 9 +- .../local-service/internal/rpc/entry_dto.go | 45 +++-- .../local-service/internal/rpc/handlers.go | 8 +- .../internal/rpc/rpc_test_helpers_test.go | 2 +- 9 files changed, 294 insertions(+), 44 deletions(-) diff --git a/services/local-service/internal/orchestrator/input_submit.go b/services/local-service/internal/orchestrator/input_submit.go index e45015c6e..c1b2e02ef 100644 --- a/services/local-service/internal/orchestrator/input_submit.go +++ b/services/local-service/internal/orchestrator/input_submit.go @@ -12,7 +12,13 @@ import ( // 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(request SubmitInputRequest) (TaskEntryResponse, error) { - response, err := s.submitInput(request.paramsMap()) + 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) { + response, err := s.submitInput(params) if err != nil { return TaskEntryResponse{}, err } diff --git a/services/local-service/internal/orchestrator/protocol_dto_test.go b/services/local-service/internal/orchestrator/protocol_dto_test.go index 7fd91083e..ab21934e4 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test.go @@ -135,7 +135,7 @@ func TestTaskDetailGetResponseMapNormalizesUnknownFields(t *testing.T) { "approval_request": nil, "authorization_record": nil, "audit_record": nil, - "security_summary": map[string]any{"pending_authorizations": 0, "latest_restore_point": nil, "unknown": "drop-me"}, + "security_summary": map[string]any{"security_status": "normal", "risk_level": "green", "pending_authorizations": 0, "latest_restore_point": nil, "unknown": "drop-me"}, "runtime_summary": map[string]any{"loop_stop_reason": nil, "events_count": 0, "latest_event_type": nil, "active_steering_count": 0, "latest_failure_code": nil, "latest_failure_category": nil, "latest_failure_summary": nil, "observation_signals": []string{}, "unknown": "drop-me"}, "unknown": "drop-me", }) @@ -195,7 +195,7 @@ func TestTaskDetailGetResponseMapDropsAuthorizationRecordRunID(t *testing.T) { "created_at": "2026-05-10T00:00:02Z", }, "audit_record": nil, - "security_summary": map[string]any{"pending_authorizations": 0, "latest_restore_point": nil}, + "security_summary": map[string]any{"security_status": "normal", "risk_level": "yellow", "pending_authorizations": 0, "latest_restore_point": nil}, "runtime_summary": map[string]any{"events_count": 0, "active_steering_count": 0, "observation_signals": []string{}}, }) if err != nil { @@ -242,3 +242,176 @@ func TestTaskDetailGetResponseRejectsMalformedDeclaredObjects(t *testing.T) { t.Fatal("expected malformed declared task object to fail response dto construction") } } + +func TestTaskDetailGetResponseRejectsMissingRequiredSummaryObjects(t *testing.T) { + basePayload := map[string]any{ + "task": map[string]any{ + "task_id": "task_detail_required_objects", + "title": "Required task detail objects", + "source_type": "floating_ball", + "status": "completed", + "current_step": "deliver_result", + "risk_level": "green", + "updated_at": "2026-05-10T00:00:01Z", + }, + "timeline": []map[string]any{}, + "delivery_result": nil, + "artifacts": []map[string]any{}, + "citations": []map[string]any{}, + "mirror_references": []map[string]any{}, + "approval_request": nil, + "authorization_record": nil, + "audit_record": nil, + "security_summary": map[string]any{ + "security_status": "safe", + "risk_level": "green", + "pending_authorizations": 0, + "latest_restore_point": nil, + }, + "runtime_summary": map[string]any{ + "events_count": 0, + "active_steering_count": 0, + "observation_signals": []string{}, + "latest_event_type": nil, + "latest_failure_code": nil, + "latest_failure_category": nil, + "latest_failure_summary": nil, + "loop_stop_reason": nil, + }, + } + + testCases := []struct { + name string + missing string + }{ + {name: "missing security_summary", missing: "security_summary"}, + {name: "missing runtime_summary", missing: "runtime_summary"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + payload := cloneMap(basePayload) + delete(payload, testCase.missing) + if _, err := newTaskDetailGetResponse(payload); err == nil { + t.Fatalf("expected missing %s to fail task detail dto construction", testCase.missing) + } + }) + } +} + +func TestTaskDetailGetResponseRejectsMissingRequiredSummaryFields(t *testing.T) { + testCases := []struct { + name string + payload map[string]any + }{ + { + name: "security_summary pending_authorizations", + payload: map[string]any{ + "task": map[string]any{ + "task_id": "task_detail_missing_security_field", + "title": "Missing security field", + "source_type": "floating_ball", + "status": "completed", + "current_step": "deliver_result", + "risk_level": "green", + "updated_at": "2026-05-10T00:00:01Z", + }, + "timeline": []map[string]any{}, + "delivery_result": nil, + "artifacts": []map[string]any{}, + "citations": []map[string]any{}, + "mirror_references": []map[string]any{}, + "approval_request": nil, + "authorization_record": nil, + "audit_record": nil, + "security_summary": map[string]any{ + "security_status": "safe", + "risk_level": "green", + "latest_restore_point": nil, + }, + "runtime_summary": map[string]any{ + "events_count": 0, + "active_steering_count": 0, + "observation_signals": []string{}, + }, + }, + }, + { + name: "runtime_summary observation_signals", + payload: map[string]any{ + "task": map[string]any{ + "task_id": "task_detail_missing_runtime_field", + "title": "Missing runtime field", + "source_type": "floating_ball", + "status": "completed", + "current_step": "deliver_result", + "risk_level": "green", + "updated_at": "2026-05-10T00:00:01Z", + }, + "timeline": []map[string]any{}, + "delivery_result": nil, + "artifacts": []map[string]any{}, + "citations": []map[string]any{}, + "mirror_references": []map[string]any{}, + "approval_request": nil, + "authorization_record": nil, + "audit_record": nil, + "security_summary": map[string]any{ + "security_status": "safe", + "risk_level": "green", + "pending_authorizations": 0, + "latest_restore_point": nil, + }, + "runtime_summary": map[string]any{ + "events_count": 0, + "active_steering_count": 0, + }, + }, + }, + { + name: "delivery_result payload", + payload: map[string]any{ + "task": map[string]any{ + "task_id": "task_detail_missing_delivery_payload", + "title": "Missing delivery payload", + "source_type": "floating_ball", + "status": "completed", + "current_step": "deliver_result", + "risk_level": "green", + "updated_at": "2026-05-10T00:00:01Z", + }, + "timeline": []map[string]any{}, + "delivery_result": map[string]any{ + "type": "bubble", + "title": "Result", + "preview_text": "Preview", + }, + "artifacts": []map[string]any{}, + "citations": []map[string]any{}, + "mirror_references": []map[string]any{}, + "approval_request": nil, + "authorization_record": nil, + "audit_record": nil, + "security_summary": map[string]any{ + "security_status": "safe", + "risk_level": "green", + "pending_authorizations": 0, + "latest_restore_point": nil, + }, + "runtime_summary": map[string]any{ + "events_count": 0, + "active_steering_count": 0, + "observation_signals": []string{}, + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + if _, err := newTaskDetailGetResponse(testCase.payload); err == nil { + t.Fatalf("expected missing required declared field to fail for %s", testCase.name) + } + }) + } +} 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 index b3002eaaf..029c71ba0 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go @@ -1,7 +1,7 @@ package orchestrator func startTaskForTest(s *Service, params map[string]any) (map[string]any, error) { - response, err := s.StartTask(StartTaskRequestFromParams(params)) + response, err := s.StartTaskFromParams(StartTaskRequestFromParams(params).ProtocolParamsMap()) if err != nil { return nil, err } @@ -9,7 +9,7 @@ func startTaskForTest(s *Service, params map[string]any) (map[string]any, error) } func submitInputForTest(s *Service, params map[string]any) (map[string]any, error) { - response, err := s.SubmitInput(SubmitInputRequestFromParams(params)) + response, err := s.SubmitInputFromParams(SubmitInputRequestFromParams(params).ProtocolParamsMap()) if err != nil { return nil, err } @@ -17,7 +17,7 @@ func submitInputForTest(s *Service, params map[string]any) (map[string]any, erro } func taskDetailGetForTest(s *Service, params map[string]any) (map[string]any, error) { - response, err := s.TaskDetailGet(TaskDetailGetRequestFromParams(params)) + response, err := s.TaskDetailGetFromParams(TaskDetailGetRequestFromParams(params).ProtocolParamsMap()) if err != nil { return nil, err } diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index e0d32bd51..70f24042a 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -374,7 +374,7 @@ func newTaskDetailGetResponse(payload map[string]any) (TaskDetailGetResponse, er if err != nil { return TaskDetailGetResponse{}, err } - securitySummaryPayload, _, err := protocolMapField(payload, "security_summary") + securitySummaryPayload, err := requireProtocolMapField(payload, "security_summary") if err != nil { return TaskDetailGetResponse{}, err } @@ -382,7 +382,7 @@ func newTaskDetailGetResponse(payload map[string]any) (TaskDetailGetResponse, er if err != nil { return TaskDetailGetResponse{}, fmt.Errorf("security_summary: %w", err) } - runtimeSummaryPayload, _, err := protocolMapField(payload, "runtime_summary") + runtimeSummaryPayload, err := requireProtocolMapField(payload, "runtime_summary") if err != nil { return TaskDetailGetResponse{}, err } @@ -595,15 +595,15 @@ func deliveryResultDTOPointerFromMap(values map[string]any, key string) (*Delive } func deliveryResultDTOFromMap(values map[string]any) (DeliveryResultDTO, error) { - resultType, err := protocolStringField(values, "type") + resultType, err := requireProtocolStringField(values, "type") if err != nil { return DeliveryResultDTO{}, err } - title, err := protocolStringField(values, "title") + title, err := requireProtocolStringField(values, "title") if err != nil { return DeliveryResultDTO{}, err } - payloadMap, _, err := protocolMapField(values, "payload") + payloadMap, err := requireProtocolMapField(values, "payload") if err != nil { return DeliveryResultDTO{}, err } @@ -611,7 +611,7 @@ func deliveryResultDTOFromMap(values map[string]any) (DeliveryResultDTO, error) if err != nil { return DeliveryResultDTO{}, fmt.Errorf("payload: %w", err) } - previewText, err := protocolStringField(values, "preview_text") + previewText, err := requireProtocolStringField(values, "preview_text") if err != nil { return DeliveryResultDTO{}, err } @@ -1075,15 +1075,15 @@ func recoveryPointDTOFromMap(values map[string]any) (RecoveryPointDTO, error) { } func securitySummaryDTOFromMap(values map[string]any) (SecuritySummaryDTO, error) { - securityStatus, err := protocolStringField(values, "security_status") + securityStatus, err := requireProtocolStringField(values, "security_status") if err != nil { return SecuritySummaryDTO{}, err } - riskLevel, err := protocolStringField(values, "risk_level") + riskLevel, err := requireProtocolStringField(values, "risk_level") if err != nil { return SecuritySummaryDTO{}, err } - pendingAuthorizations, err := protocolIntField(values, "pending_authorizations") + pendingAuthorizations, err := requireProtocolIntField(values, "pending_authorizations") if err != nil { return SecuritySummaryDTO{}, err } @@ -1104,7 +1104,7 @@ func runtimeSummaryDTOFromMap(values map[string]any) (TaskRuntimeSummaryDTO, err if err != nil { return TaskRuntimeSummaryDTO{}, err } - eventsCount, err := protocolIntField(values, "events_count") + eventsCount, err := requireProtocolIntField(values, "events_count") if err != nil { return TaskRuntimeSummaryDTO{}, err } @@ -1112,7 +1112,7 @@ func runtimeSummaryDTOFromMap(values map[string]any) (TaskRuntimeSummaryDTO, err if err != nil { return TaskRuntimeSummaryDTO{}, err } - activeSteeringCount, err := protocolIntField(values, "active_steering_count") + activeSteeringCount, err := requireProtocolIntField(values, "active_steering_count") if err != nil { return TaskRuntimeSummaryDTO{}, err } @@ -1128,7 +1128,7 @@ func runtimeSummaryDTOFromMap(values map[string]any) (TaskRuntimeSummaryDTO, err if err != nil { return TaskRuntimeSummaryDTO{}, err } - observationSignals, err := protocolStringSliceField(values, "observation_signals") + observationSignals, err := requireProtocolStringSliceField(values, "observation_signals") if err != nil { return TaskRuntimeSummaryDTO{}, err } @@ -1156,6 +1156,17 @@ func protocolMapField(values map[string]any, key string) (map[string]any, bool, 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 protocolMapSliceField(values map[string]any, key string) ([]map[string]any, error) { rawValue, ok := values[key] if !ok || rawValue == nil { @@ -1195,6 +1206,17 @@ func protocolStringField(values map[string]any, key string) (string, error) { return value, nil } +func requireProtocolStringField(values map[string]any, key string) (string, error) { + value, err := protocolStringField(values, key) + if err != nil { + return "", err + } + if strings.TrimSpace(value) == "" { + return "", fmt.Errorf("%s must be string", key) + } + return value, nil +} + func protocolStringPointerField(values map[string]any, key string) (*string, error) { rawValue, ok := values[key] if !ok || rawValue == nil { @@ -1240,6 +1262,14 @@ func protocolIntField(values map[string]any, key string) (int, error) { } } +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 { @@ -1263,6 +1293,14 @@ func protocolStringSliceField(values map[string]any, key string) ([]string, erro } } +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 diff --git a/services/local-service/internal/orchestrator/task_query.go b/services/local-service/internal/orchestrator/task_query.go index 250528310..635660927 100644 --- a/services/local-service/internal/orchestrator/task_query.go +++ b/services/local-service/internal/orchestrator/task_query.go @@ -39,7 +39,13 @@ func (s *Service) TaskList(params map[string]any) (map[string]any, error) { // 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(request TaskDetailGetRequest) (TaskDetailGetResponse, error) { - response, err := s.taskDetailGet(request.paramsMap()) + return s.TaskDetailGetFromParams(request.ProtocolParamsMap()) +} + +// TaskDetailGetFromParams lets the RPC layer pass its normalized stable payload +// straight into task-detail assembly without rebuilding the same protocol map. +func (s *Service) TaskDetailGetFromParams(params map[string]any) (TaskDetailGetResponse, error) { + response, err := s.taskDetailGet(params) if err != nil { return TaskDetailGetResponse{}, err } @@ -92,6 +98,19 @@ func (s *Service) taskDetailGet(params map[string]any) (map[string]any, error) { if approvalRequest != nil { securitySummary["pending_authorizations"] = 1 } + if strings.TrimSpace(stringValue(securitySummary, "security_status", "")) == "" { + if approvalRequest != nil { + securitySummary["security_status"] = "pending_confirmation" + } else { + securitySummary["security_status"] = "normal" + } + } + 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 latestRestorePoint == nil { securitySummary["latest_restore_point"] = nil diff --git a/services/local-service/internal/orchestrator/task_start.go b/services/local-service/internal/orchestrator/task_start.go index 6a1a964af..0547b6942 100644 --- a/services/local-service/internal/orchestrator/task_start.go +++ b/services/local-service/internal/orchestrator/task_start.go @@ -14,7 +14,14 @@ import ( // inferred intent. Object-only starts stay in confirmation unless the caller // supplied enough instruction to enter governance and execution immediately. func (s *Service) StartTask(request StartTaskRequest) (TaskEntryResponse, error) { - response, err := s.startTask(request.paramsMap()) + 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) { + response, err := s.startTask(params) if err != nil { return TaskEntryResponse{}, err } diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index 9e495b1fa..0e492400f 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -80,25 +80,28 @@ type AgentTaskStartParams = orchestrator.StartTaskRequest type AgentTaskDetailGetParams = orchestrator.TaskDetailGetRequest func decodeAgentInputSubmitParams(raw json.RawMessage) (map[string]any, *rpcError) { - var params AgentInputSubmitParams - return decodeTypedProtocolParams(raw, ¶ms, validateAgentInputSubmitParams) + return decodeTypedProtocolParams(raw, validateAgentInputSubmitParams, func(payload map[string]any) map[string]any { + return orchestrator.SubmitInputRequestFromParams(payload).ProtocolParamsMap() + }) } func decodeAgentTaskStartParams(raw json.RawMessage) (map[string]any, *rpcError) { - var params AgentTaskStartParams - return decodeTypedProtocolParams(raw, ¶ms, validateAgentTaskStartParams) + return decodeTypedProtocolParams(raw, validateAgentTaskStartParams, func(payload map[string]any) map[string]any { + return orchestrator.StartTaskRequestFromParams(withoutTopLevelField(payload, "intent")).ProtocolParamsMap() + }) } func decodeAgentTaskDetailGetParams(raw json.RawMessage) (map[string]any, *rpcError) { - var params AgentTaskDetailGetParams - return decodeTypedProtocolParams(raw, ¶ms, validateAgentTaskDetailGetParams) + return decodeTypedProtocolParams(raw, validateAgentTaskDetailGetParams, func(payload map[string]any) map[string]any { + return orchestrator.TaskDetailGetRequestFromParams(payload).ProtocolParamsMap() + }) } func decodeAgentTaskListParams(raw json.RawMessage) (map[string]any, *rpcError) { return decodeParamsWithValidation(raw, validateAgentTaskListParams) } -func decodeTypedProtocolParams(raw json.RawMessage, target any, validate func(map[string]any) *rpcError) (map[string]any, *rpcError) { +func decodeTypedProtocolParams(raw json.RawMessage, validate func(map[string]any) *rpcError, normalize func(map[string]any) map[string]any) (map[string]any, *rpcError) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { trimmed = []byte("{}") @@ -117,18 +120,10 @@ func decodeTypedProtocolParams(raw json.RawMessage, target any, validate func(ma return nil, 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 normalized, ok := target.(interface{ ProtocolParamsMap() map[string]any }); ok { - return normalized.ProtocolParamsMap(), nil + if normalize != nil { + return normalize(payload), nil } - return protocolParamsMap(target) + return payload, nil } func decodeParamsRequiringRequestMeta(raw json.RawMessage) (map[string]any, *rpcError) { @@ -148,6 +143,20 @@ func decodeParamsWithValidation(raw json.RawMessage, validate func(map[string]an return params, nil } +func withoutTopLevelField(values map[string]any, field string) map[string]any { + if len(values) == 0 { + return map[string]any{} + } + result := make(map[string]any, len(values)) + for key, value := range values { + if key == field { + continue + } + result[key] = value + } + return result +} + func validateAgentInputSubmitParams(params map[string]any) *rpcError { if err := requireRequestMeta(params); err != nil { return err diff --git a/services/local-service/internal/rpc/handlers.go b/services/local-service/internal/rpc/handlers.go index 13aff6a31..3d3076158 100644 --- a/services/local-service/internal/rpc/handlers.go +++ b/services/local-service/internal/rpc/handlers.go @@ -1,8 +1,6 @@ // Package rpc routes stable JSON-RPC methods into the main orchestrator. package rpc -import "github.com/cialloclaw/cialloclaw/services/local-service/internal/orchestrator" - // registerHandlers binds stable agent.* JSON-RPC methods to their protocol // decoders and orchestrator entry points. func (s *Server) registerHandlers() { @@ -34,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(orchestrator.SubmitInputRequestFromParams(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(orchestrator.StartTaskRequestFromParams(params)) + data, err := s.orchestrator.StartTaskFromParams(params) return wrapOrchestratorResult(data, err) } @@ -71,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(orchestrator.TaskDetailGetRequestFromParams(params)) + data, err := s.orchestrator.TaskDetailGetFromParams(params) return wrapOrchestratorResult(data, err) } 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 51d073455..3e93be2aa 100644 --- a/services/local-service/internal/rpc/rpc_test_helpers_test.go +++ b/services/local-service/internal/rpc/rpc_test_helpers_test.go @@ -215,7 +215,7 @@ func rpcRequestMeta(traceID string) map[string]any { } func startTaskForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { - response, err := s.StartTask(orchestrator.StartTaskRequestFromParams(params)) + response, err := s.StartTaskFromParams(orchestrator.StartTaskRequestFromParams(params).ProtocolParamsMap()) if err != nil { return nil, err } From e8954d34f92fbc562dc9817c02bea368ef869d76 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 15:07:29 +0000 Subject: [PATCH 24/41] docs(protocol): unify shared input context contract Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- docs/protocol-design.md | 69 +++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/docs/protocol-design.md b/docs/protocol-design.md index 57703857b..bcff956d4 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,15 +837,7 @@ Notification 只负责“状态变化推送”,不承载复杂业务命令。 | `input.page_context.app_name` | 当前宿主应用名 | | `input.page_context.window_title` | 当前窗口标题 | | `input.page_context.visible_text` | 当前页面可见文本摘录 | -| `context.selection.text` | 当前选区补充文本,按需传入 | -| `context.selection_text` | 当前选区的平铺补充文本,按需传入 | -| `context.files` | 补充文件上下文,按需传入 | -| `context.file_paths` | 兼容文件路径列表,按需传入 | -| `context.error.message` | 当前错误文本,按需传入 | -| `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` | 是否强制先进入意图确认;不用于绕过风险授权 | From d3a6b04912251d3b1829b6db7ad62b2534706647 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 16:08:01 +0000 Subject: [PATCH 25/41] fix(local-service): harden typed dto boundary validation Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../orchestrator/protocol_dto_test.go | 131 ++++++++++++++ .../orchestrator/protocol_response_dto.go | 163 ++++++++++-------- .../local-service/internal/rpc/entry_dto.go | 44 +++-- .../internal/rpc/protocol_registry_test.go | 90 ++++++++++ 4 files changed, 331 insertions(+), 97 deletions(-) diff --git a/services/local-service/internal/orchestrator/protocol_dto_test.go b/services/local-service/internal/orchestrator/protocol_dto_test.go index ab21934e4..f3a86de4a 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test.go @@ -415,3 +415,134 @@ func TestTaskDetailGetResponseRejectsMissingRequiredSummaryFields(t *testing.T) }) } } + +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 TestTaskDetailGetResponseRejectsMissingRequiredDeclaredFields(t *testing.T) { + testCases := []struct { + name string + payload map[string]any + }{ + { + name: "task.task_id", + payload: map[string]any{ + "task": map[string]any{ + "title": "Missing task id", + "source_type": "floating_ball", + "status": "completed", + "current_step": "deliver_result", + "risk_level": "green", + "updated_at": "2026-05-10T00:00:01Z", + }, + "timeline": []map[string]any{}, + "delivery_result": nil, + "artifacts": []map[string]any{}, + "citations": []map[string]any{}, + "mirror_references": []map[string]any{}, + "approval_request": nil, + "authorization_record": nil, + "audit_record": nil, + "security_summary": map[string]any{ + "security_status": "safe", + "risk_level": "green", + "pending_authorizations": 0, + "latest_restore_point": nil, + }, + "runtime_summary": map[string]any{ + "events_count": 0, + "active_steering_count": 0, + "observation_signals": []string{}, + }, + }, + }, + { + name: "timeline array", + payload: map[string]any{ + "task": map[string]any{ + "task_id": "task_missing_timeline", + "title": "Missing timeline", + "source_type": "floating_ball", + "status": "completed", + "current_step": "deliver_result", + "risk_level": "green", + "updated_at": "2026-05-10T00:00:01Z", + }, + "delivery_result": nil, + "artifacts": []map[string]any{}, + "citations": []map[string]any{}, + "mirror_references": []map[string]any{}, + "approval_request": nil, + "authorization_record": nil, + "audit_record": nil, + "security_summary": map[string]any{ + "security_status": "safe", + "risk_level": "green", + "pending_authorizations": 0, + "latest_restore_point": nil, + }, + "runtime_summary": map[string]any{ + "events_count": 0, + "active_steering_count": 0, + "observation_signals": []string{}, + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + if _, err := newTaskDetailGetResponse(testCase.payload); err == nil { + t.Fatalf("expected missing required declared field to fail for %s", testCase.name) + } + }) + } +} diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index 70f24042a..e62aa90a6 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -433,7 +433,7 @@ func taskDTOPointerFromMap(values map[string]any, key string) (*TaskDTO, error) } func taskDTOFromMap(values map[string]any) (TaskDTO, error) { - taskID, err := protocolStringField(values, "task_id") + taskID, err := requireProtocolStringField(values, "task_id") if err != nil { return TaskDTO{}, err } @@ -441,15 +441,15 @@ func taskDTOFromMap(values map[string]any) (TaskDTO, error) { if err != nil { return TaskDTO{}, err } - title, err := protocolStringField(values, "title") + title, err := requireProtocolStringField(values, "title") if err != nil { return TaskDTO{}, err } - sourceType, err := protocolStringField(values, "source_type") + sourceType, err := requireProtocolStringField(values, "source_type") if err != nil { return TaskDTO{}, err } - status, err := protocolStringField(values, "status") + status, err := requireProtocolStringField(values, "status") if err != nil { return TaskDTO{}, err } @@ -457,11 +457,11 @@ func taskDTOFromMap(values map[string]any) (TaskDTO, error) { if err != nil { return TaskDTO{}, err } - currentStep, err := protocolStringField(values, "current_step") + currentStep, err := requireProtocolStringField(values, "current_step") if err != nil { return TaskDTO{}, err } - riskLevel, err := protocolStringField(values, "risk_level") + riskLevel, err := requireProtocolStringField(values, "risk_level") if err != nil { return TaskDTO{}, err } @@ -473,7 +473,7 @@ func taskDTOFromMap(values map[string]any) (TaskDTO, error) { if err != nil { return TaskDTO{}, err } - updatedAt, err := protocolStringField(values, "updated_at") + updatedAt, err := requireProtocolStringField(values, "updated_at") if err != nil { return TaskDTO{}, err } @@ -540,31 +540,31 @@ func bubbleMessageDTOPointerFromMap(values map[string]any, key string) (*BubbleM } func bubbleMessageDTOFromMap(values map[string]any) (BubbleMessageDTO, error) { - bubbleID, err := protocolStringField(values, "bubble_id") + bubbleID, err := requireProtocolStringField(values, "bubble_id") if err != nil { return BubbleMessageDTO{}, err } - taskID, err := protocolStringField(values, "task_id") + taskID, err := requireProtocolStringField(values, "task_id") if err != nil { return BubbleMessageDTO{}, err } - messageType, err := protocolStringField(values, "type") + messageType, err := requireProtocolStringField(values, "type") if err != nil { return BubbleMessageDTO{}, err } - text, err := protocolStringField(values, "text") + text, err := requireProtocolStringField(values, "text") if err != nil { return BubbleMessageDTO{}, err } - pinned, err := protocolBoolField(values, "pinned") + pinned, err := requireProtocolBoolField(values, "pinned") if err != nil { return BubbleMessageDTO{}, err } - hidden, err := protocolBoolField(values, "hidden") + hidden, err := requireProtocolBoolField(values, "hidden") if err != nil { return BubbleMessageDTO{}, err } - createdAt, err := protocolStringField(values, "created_at") + createdAt, err := requireProtocolStringField(values, "created_at") if err != nil { return BubbleMessageDTO{}, err } @@ -640,7 +640,7 @@ func deliveryPayloadDTOFromMap(values map[string]any) (DeliveryPayloadDTO, error } func taskStepDTOListFromMap(values map[string]any, key string) ([]TaskStepDTO, error) { - items, err := protocolMapSliceField(values, key) + items, err := requireProtocolMapSliceField(values, key) if err != nil { return nil, err } @@ -656,31 +656,31 @@ func taskStepDTOListFromMap(values map[string]any, key string) ([]TaskStepDTO, e } func taskStepDTOFromMap(values map[string]any) (TaskStepDTO, error) { - stepID, err := protocolStringField(values, "step_id") + stepID, err := requireProtocolStringField(values, "step_id") if err != nil { return TaskStepDTO{}, err } - taskID, err := protocolStringField(values, "task_id") + taskID, err := requireProtocolStringField(values, "task_id") if err != nil { return TaskStepDTO{}, err } - name, err := protocolStringField(values, "name") + name, err := requireProtocolStringField(values, "name") if err != nil { return TaskStepDTO{}, err } - status, err := protocolStringField(values, "status") + status, err := requireProtocolStringField(values, "status") if err != nil { return TaskStepDTO{}, err } - orderIndex, err := protocolIntField(values, "order_index") + orderIndex, err := requireProtocolIntField(values, "order_index") if err != nil { return TaskStepDTO{}, err } - inputSummary, err := protocolStringField(values, "input_summary") + inputSummary, err := requireProtocolStringField(values, "input_summary") if err != nil { return TaskStepDTO{}, err } - outputSummary, err := protocolStringField(values, "output_summary") + outputSummary, err := requireProtocolStringField(values, "output_summary") if err != nil { return TaskStepDTO{}, err } @@ -696,7 +696,7 @@ func taskStepDTOFromMap(values map[string]any) (TaskStepDTO, error) { } func artifactDTOListFromMap(values map[string]any, key string) ([]ArtifactDTO, error) { - items, err := protocolMapSliceField(values, key) + items, err := requireProtocolMapSliceField(values, key) if err != nil { return nil, err } @@ -712,27 +712,27 @@ func artifactDTOListFromMap(values map[string]any, key string) ([]ArtifactDTO, e } func artifactDTOFromMap(values map[string]any) (ArtifactDTO, error) { - artifactID, err := protocolStringField(values, "artifact_id") + artifactID, err := requireProtocolStringField(values, "artifact_id") if err != nil { return ArtifactDTO{}, err } - taskID, err := protocolStringField(values, "task_id") + taskID, err := requireProtocolStringField(values, "task_id") if err != nil { return ArtifactDTO{}, err } - artifactType, err := protocolStringField(values, "artifact_type") + artifactType, err := requireProtocolStringField(values, "artifact_type") if err != nil { return ArtifactDTO{}, err } - title, err := protocolStringField(values, "title") + title, err := requireProtocolStringField(values, "title") if err != nil { return ArtifactDTO{}, err } - path, err := protocolStringField(values, "path") + path, err := requireProtocolStringField(values, "path") if err != nil { return ArtifactDTO{}, err } - mimeType, err := protocolStringField(values, "mime_type") + mimeType, err := requireProtocolStringField(values, "mime_type") if err != nil { return ArtifactDTO{}, err } @@ -747,7 +747,7 @@ func artifactDTOFromMap(values map[string]any) (ArtifactDTO, error) { } func citationDTOListFromMap(values map[string]any, key string) ([]CitationDTO, error) { - items, err := protocolMapSliceField(values, key) + items, err := requireProtocolMapSliceField(values, key) if err != nil { return nil, err } @@ -763,27 +763,27 @@ func citationDTOListFromMap(values map[string]any, key string) ([]CitationDTO, e } func citationDTOFromMap(values map[string]any) (CitationDTO, error) { - citationID, err := protocolStringField(values, "citation_id") + citationID, err := requireProtocolStringField(values, "citation_id") if err != nil { return CitationDTO{}, err } - taskID, err := protocolStringField(values, "task_id") + taskID, err := requireProtocolStringField(values, "task_id") if err != nil { return CitationDTO{}, err } - runID, err := protocolStringField(values, "run_id") + runID, err := requireProtocolStringField(values, "run_id") if err != nil { return CitationDTO{}, err } - sourceType, err := protocolStringField(values, "source_type") + sourceType, err := requireProtocolStringField(values, "source_type") if err != nil { return CitationDTO{}, err } - sourceRef, err := protocolStringField(values, "source_ref") + sourceRef, err := requireProtocolStringField(values, "source_ref") if err != nil { return CitationDTO{}, err } - label, err := protocolStringField(values, "label") + label, err := requireProtocolStringField(values, "label") if err != nil { return CitationDTO{}, err } @@ -823,7 +823,7 @@ func citationDTOFromMap(values map[string]any) (CitationDTO, error) { } func mirrorReferenceDTOListFromMap(values map[string]any, key string) ([]MirrorReferenceDTO, error) { - items, err := protocolMapSliceField(values, key) + items, err := requireProtocolMapSliceField(values, key) if err != nil { return nil, err } @@ -839,15 +839,15 @@ func mirrorReferenceDTOListFromMap(values map[string]any, key string) ([]MirrorR } func mirrorReferenceDTOFromMap(values map[string]any) (MirrorReferenceDTO, error) { - memoryID, err := protocolStringField(values, "memory_id") + memoryID, err := requireProtocolStringField(values, "memory_id") if err != nil { return MirrorReferenceDTO{}, err } - reason, err := protocolStringField(values, "reason") + reason, err := requireProtocolStringField(values, "reason") if err != nil { return MirrorReferenceDTO{}, err } - summary, err := protocolStringField(values, "summary") + summary, err := requireProtocolStringField(values, "summary") if err != nil { return MirrorReferenceDTO{}, err } @@ -870,35 +870,35 @@ func approvalRequestDTOPointerFromMap(values map[string]any, key string) (*Appro } func approvalRequestDTOFromMap(values map[string]any) (ApprovalRequestDTO, error) { - approvalID, err := protocolStringField(values, "approval_id") + approvalID, err := requireProtocolStringField(values, "approval_id") if err != nil { return ApprovalRequestDTO{}, err } - taskID, err := protocolStringField(values, "task_id") + taskID, err := requireProtocolStringField(values, "task_id") if err != nil { return ApprovalRequestDTO{}, err } - operationName, err := protocolStringField(values, "operation_name") + operationName, err := requireProtocolStringField(values, "operation_name") if err != nil { return ApprovalRequestDTO{}, err } - riskLevel, err := protocolStringField(values, "risk_level") + riskLevel, err := requireProtocolStringField(values, "risk_level") if err != nil { return ApprovalRequestDTO{}, err } - targetObject, err := protocolStringField(values, "target_object") + targetObject, err := requireProtocolStringField(values, "target_object") if err != nil { return ApprovalRequestDTO{}, err } - reason, err := protocolStringField(values, "reason") + reason, err := requireProtocolStringField(values, "reason") if err != nil { return ApprovalRequestDTO{}, err } - status, err := protocolStringField(values, "status") + status, err := requireProtocolStringField(values, "status") if err != nil { return ApprovalRequestDTO{}, err } - createdAt, err := protocolStringField(values, "created_at") + createdAt, err := requireProtocolStringField(values, "created_at") if err != nil { return ApprovalRequestDTO{}, err } @@ -930,31 +930,31 @@ func authorizationRecordDTOPointerFromMap(values map[string]any, key string) (*A } func authorizationRecordDTOFromMap(values map[string]any) (AuthorizationRecordDTO, error) { - recordID, err := protocolStringField(values, "authorization_record_id") + recordID, err := requireProtocolStringField(values, "authorization_record_id") if err != nil { return AuthorizationRecordDTO{}, err } - taskID, err := protocolStringField(values, "task_id") + taskID, err := requireProtocolStringField(values, "task_id") if err != nil { return AuthorizationRecordDTO{}, err } - approvalID, err := protocolStringField(values, "approval_id") + approvalID, err := requireProtocolStringField(values, "approval_id") if err != nil { return AuthorizationRecordDTO{}, err } - decision, err := protocolStringField(values, "decision") + decision, err := requireProtocolStringField(values, "decision") if err != nil { return AuthorizationRecordDTO{}, err } - rememberRule, err := protocolBoolField(values, "remember_rule") + rememberRule, err := requireProtocolBoolField(values, "remember_rule") if err != nil { return AuthorizationRecordDTO{}, err } - operator, err := protocolStringField(values, "operator") + operator, err := requireProtocolStringField(values, "operator") if err != nil { return AuthorizationRecordDTO{}, err } - createdAt, err := protocolStringField(values, "created_at") + createdAt, err := requireProtocolStringField(values, "created_at") if err != nil { return AuthorizationRecordDTO{}, err } @@ -985,35 +985,35 @@ func auditRecordDTOPointerFromMap(values map[string]any, key string) (*AuditReco } func auditRecordDTOFromMap(values map[string]any) (AuditRecordDTO, error) { - auditID, err := protocolStringField(values, "audit_id") + auditID, err := requireProtocolStringField(values, "audit_id") if err != nil { return AuditRecordDTO{}, err } - taskID, err := protocolStringField(values, "task_id") + taskID, err := requireProtocolStringField(values, "task_id") if err != nil { return AuditRecordDTO{}, err } - recordType, err := protocolStringField(values, "type") + recordType, err := requireProtocolStringField(values, "type") if err != nil { return AuditRecordDTO{}, err } - action, err := protocolStringField(values, "action") + action, err := requireProtocolStringField(values, "action") if err != nil { return AuditRecordDTO{}, err } - summary, err := protocolStringField(values, "summary") + summary, err := requireProtocolStringField(values, "summary") if err != nil { return AuditRecordDTO{}, err } - target, err := protocolStringField(values, "target") + target, err := requireProtocolStringField(values, "target") if err != nil { return AuditRecordDTO{}, err } - result, err := protocolStringField(values, "result") + result, err := requireProtocolStringField(values, "result") if err != nil { return AuditRecordDTO{}, err } - createdAt, err := protocolStringField(values, "created_at") + createdAt, err := requireProtocolStringField(values, "created_at") if err != nil { return AuditRecordDTO{}, err } @@ -1045,23 +1045,23 @@ func recoveryPointDTOPointerFromMap(values map[string]any, key string) (*Recover } func recoveryPointDTOFromMap(values map[string]any) (RecoveryPointDTO, error) { - recoveryPointID, err := protocolStringField(values, "recovery_point_id") + recoveryPointID, err := requireProtocolStringField(values, "recovery_point_id") if err != nil { return RecoveryPointDTO{}, err } - taskID, err := protocolStringField(values, "task_id") + taskID, err := requireProtocolStringField(values, "task_id") if err != nil { return RecoveryPointDTO{}, err } - summary, err := protocolStringField(values, "summary") + summary, err := requireProtocolStringField(values, "summary") if err != nil { return RecoveryPointDTO{}, err } - createdAt, err := protocolStringField(values, "created_at") + createdAt, err := requireProtocolStringField(values, "created_at") if err != nil { return RecoveryPointDTO{}, err } - objects, err := protocolStringSliceField(values, "objects") + objects, err := requireProtocolStringSliceField(values, "objects") if err != nil { return RecoveryPointDTO{}, err } @@ -1194,6 +1194,14 @@ func protocolMapSliceField(values map[string]any, key string) ([]map[string]any, } } +func requireProtocolMapSliceField(values map[string]any, key string) ([]map[string]any, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return nil, fmt.Errorf("%s must be array of objects", key) + } + return protocolMapSliceField(values, key) +} + func protocolStringField(values map[string]any, key string) (string, error) { rawValue, ok := values[key] if !ok || rawValue == nil { @@ -1207,13 +1215,14 @@ func protocolStringField(values map[string]any, key string) (string, error) { } func requireProtocolStringField(values map[string]any, key string) (string, error) { - value, err := protocolStringField(values, key) - if err != nil { - return "", err - } - if strings.TrimSpace(value) == "" { + 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 } @@ -1241,6 +1250,14 @@ func protocolBoolField(values map[string]any, key string) (bool, error) { 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 { diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index 0e492400f..cabf2e3a6 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -80,20 +80,20 @@ type AgentTaskStartParams = orchestrator.StartTaskRequest type AgentTaskDetailGetParams = orchestrator.TaskDetailGetRequest func decodeAgentInputSubmitParams(raw json.RawMessage) (map[string]any, *rpcError) { - return decodeTypedProtocolParams(raw, validateAgentInputSubmitParams, func(payload map[string]any) map[string]any { - return orchestrator.SubmitInputRequestFromParams(payload).ProtocolParamsMap() + return decodeTypedProtocolParams(raw, validateAgentInputSubmitParams, decodeTypedProtocolDTO[AgentInputSubmitParams], func(request AgentInputSubmitParams) map[string]any { + return request.ProtocolParamsMap() }) } func decodeAgentTaskStartParams(raw json.RawMessage) (map[string]any, *rpcError) { - return decodeTypedProtocolParams(raw, validateAgentTaskStartParams, func(payload map[string]any) map[string]any { - return orchestrator.StartTaskRequestFromParams(withoutTopLevelField(payload, "intent")).ProtocolParamsMap() + return decodeTypedProtocolParams(raw, validateAgentTaskStartParams, decodeTypedProtocolDTO[AgentTaskStartParams], func(request AgentTaskStartParams) map[string]any { + return request.ProtocolParamsMap() }) } func decodeAgentTaskDetailGetParams(raw json.RawMessage) (map[string]any, *rpcError) { - return decodeTypedProtocolParams(raw, validateAgentTaskDetailGetParams, func(payload map[string]any) map[string]any { - return orchestrator.TaskDetailGetRequestFromParams(payload).ProtocolParamsMap() + return decodeTypedProtocolParams(raw, validateAgentTaskDetailGetParams, decodeTypedProtocolDTO[AgentTaskDetailGetParams], func(request AgentTaskDetailGetParams) map[string]any { + return request.ProtocolParamsMap() }) } @@ -101,7 +101,7 @@ func decodeAgentTaskListParams(raw json.RawMessage) (map[string]any, *rpcError) return decodeParamsWithValidation(raw, validateAgentTaskListParams) } -func decodeTypedProtocolParams(raw json.RawMessage, validate func(map[string]any) *rpcError, normalize func(map[string]any) map[string]any) (map[string]any, *rpcError) { +func decodeTypedProtocolParams[T any](raw json.RawMessage, validate func(map[string]any) *rpcError, decode func([]byte) (T, error), 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("{}") @@ -120,10 +120,20 @@ func decodeTypedProtocolParams(raw json.RawMessage, validate func(map[string]any return nil, err } } - if normalize != nil { - return normalize(payload), nil + if decode != nil && normalize != nil { + typedPayload, err := decode(trimmed) + if err != nil { + return nil, invalidParamsError("params do not match the registered method dto") + } + return normalize(typedPayload), nil } - return payload, nil + return map[string]any{}, nil +} + +func decodeTypedProtocolDTO[T any](raw []byte) (T, error) { + var dto T + err := json.Unmarshal(raw, &dto) + return dto, err } func decodeParamsRequiringRequestMeta(raw json.RawMessage) (map[string]any, *rpcError) { @@ -143,20 +153,6 @@ func decodeParamsWithValidation(raw json.RawMessage, validate func(map[string]an return params, nil } -func withoutTopLevelField(values map[string]any, field string) map[string]any { - if len(values) == 0 { - return map[string]any{} - } - result := make(map[string]any, len(values)) - for key, value := range values { - if key == field { - continue - } - result[key] = value - } - return result -} - func validateAgentInputSubmitParams(params map[string]any) *rpcError { if err := requireRequestMeta(params); err != nil { return err diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index d7e62f523..d48f92966 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -399,6 +399,96 @@ func TestAgentTaskStartDTORejectsOutOfDomainPageContextBrowserWithoutContext(t * } } +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{ From b8b5c78bdb6468bc0f60f508d3d89c5b424b6a7e Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 16:25:31 +0000 Subject: [PATCH 26/41] refactor(local-service): stop hand-assembling typed dto maps Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/orchestrator/approval_state.go | 2 +- .../orchestrator/protocol_dto_test.go | 2 +- .../orchestrator/protocol_response_dto.go | 387 +++--------------- .../internal/orchestrator/screen_analyze.go | 74 ++-- .../orchestrator/screen_approval_state.go | 50 +-- .../local-service/internal/rpc/entry_dto.go | 22 - 6 files changed, 117 insertions(+), 420 deletions(-) diff --git a/services/local-service/internal/orchestrator/approval_state.go b/services/local-service/internal/orchestrator/approval_state.go index f870ce3cf..f54ea8b23 100644 --- a/services/local-service/internal/orchestrator/approval_state.go +++ b/services/local-service/internal/orchestrator/approval_state.go @@ -18,7 +18,7 @@ func (s *Service) resumeQueuedControlledTask(task runengine.TaskRecord) (runengi } 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() diff --git a/services/local-service/internal/orchestrator/protocol_dto_test.go b/services/local-service/internal/orchestrator/protocol_dto_test.go index f3a86de4a..8226900d3 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test.go @@ -29,7 +29,7 @@ func TestStartTaskRequestFromParamsNormalizesUnknownFields(t *testing.T) { "unknown_field": "drop-me", }) - params := request.paramsMap() + params := request.ProtocolParamsMap() if _, ok := params["unknown_field"]; ok { t.Fatalf("expected typed request normalization to drop unknown top-level fields, got %+v", params) } diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index e62aa90a6..10670a67d 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -184,6 +184,34 @@ type TaskDetailGetResponse struct { 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. @@ -230,84 +258,44 @@ func TaskDetailGetRequestFromParams(params map[string]any) TaskDetailGetRequest } } -func (r StartTaskRequest) paramsMap() map[string]any { - return r.ProtocolParamsMap() -} - // ProtocolParamsMap exports the normalized protocol payload for RPC adapters // that have already validated the transport envelope. func (r StartTaskRequest) ProtocolParamsMap() map[string]any { - params := map[string]any{ - "request_meta": r.RequestMeta.protocolMap(), - "input": r.Input.protocolMap(), - } - if strings.TrimSpace(r.SessionID) != "" { - params["session_id"] = r.SessionID - } - if strings.TrimSpace(r.Source) != "" { - params["source"] = r.Source - } - if strings.TrimSpace(r.Trigger) != "" { - params["trigger"] = r.Trigger - } - if r.Context != nil { - params["context"] = r.Context.protocolMap() - } - if r.Delivery != nil { - params["delivery"] = r.Delivery.protocolMap() - } - if r.Options != nil { - params["options"] = r.Options.protocolMap() - } - if len(r.Intent) > 0 { - params["intent"] = cloneMap(r.Intent) - } - return params -} - -func (r SubmitInputRequest) paramsMap() map[string]any { - return r.ProtocolParamsMap() + 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 { - params := map[string]any{ - "request_meta": r.RequestMeta.protocolMap(), - "input": r.Input.protocolMap(), - } - if strings.TrimSpace(r.SessionID) != "" { - params["session_id"] = r.SessionID - } - if strings.TrimSpace(r.Source) != "" { - params["source"] = r.Source - } - if strings.TrimSpace(r.Trigger) != "" { - params["trigger"] = r.Trigger - } - if r.Context != nil { - params["context"] = r.Context.protocolMap() - } - if r.VoiceMeta != nil { - params["voice_meta"] = r.VoiceMeta.protocolMap() - } - if r.Options != nil { - params["options"] = r.Options.protocolMap() - } - return params -} - -func (r TaskDetailGetRequest) paramsMap() map[string]any { - return r.ProtocolParamsMap() + 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 map[string]any{ - "request_meta": r.RequestMeta.protocolMap(), - "task_id": r.TaskID, - } + return protocolMapFromDTO(taskDetailGetProtocolParams{ + RequestMeta: r.RequestMeta, + TaskID: strings.TrimSpace(r.TaskID), + }) } func newTaskEntryResponse(payload map[string]any) (TaskEntryResponse, error) { @@ -408,13 +396,13 @@ func newTaskDetailGetResponse(payload map[string]any) (TaskDetailGetResponse, er // 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 responseDTOToProtocolMap(r) + 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 responseDTOToProtocolMap(r) + return protocolMapFromDTO(r) } func taskDTOPointerFromMap(values map[string]any, key string) (*TaskDTO, error) { @@ -1344,13 +1332,6 @@ func requestMetaFromMap(values map[string]any) RequestMeta { } } -func (m RequestMeta) protocolMap() map[string]any { - return map[string]any{ - "trace_id": m.TraceID, - "client_time": m.ClientTime, - } -} - func pageContextPointerFromMap(values map[string]any) *PageContext { if len(values) == 0 { return nil @@ -1368,41 +1349,6 @@ func pageContextPointerFromMap(values map[string]any) *PageContext { } } -func (c *PageContext) protocolMap() map[string]any { - if c == nil { - return nil - } - params := map[string]any{} - if c.Title != "" { - params["title"] = c.Title - } - if c.AppName != "" { - params["app_name"] = c.AppName - } - if c.URL != "" { - params["url"] = c.URL - } - if c.BrowserKind != "" { - params["browser_kind"] = c.BrowserKind - } - if c.ProcessPath != "" { - params["process_path"] = c.ProcessPath - } - if c.ProcessID != 0 { - params["process_id"] = c.ProcessID - } - if c.WindowTitle != "" { - params["window_title"] = c.WindowTitle - } - if c.VisibleText != "" { - params["visible_text"] = c.VisibleText - } - if c.HoverTarget != "" { - params["hover_target"] = c.HoverTarget - } - return params -} - func screenContextPointerFromMap(values map[string]any) *ScreenContext { if len(values) == 0 { return nil @@ -1416,29 +1362,6 @@ func screenContextPointerFromMap(values map[string]any) *ScreenContext { } } -func (c *ScreenContext) protocolMap() map[string]any { - if c == nil { - return nil - } - params := map[string]any{} - if c.Summary != "" { - params["summary"] = c.Summary - } - if c.ScreenSummary != "" { - params["screen_summary"] = c.ScreenSummary - } - if c.VisibleText != "" { - params["visible_text"] = c.VisibleText - } - if c.WindowTitle != "" { - params["window_title"] = c.WindowTitle - } - if c.HoverTarget != "" { - params["hover_target"] = c.HoverTarget - } - return params -} - func behaviorContextPointerFromMap(values map[string]any) *BehaviorContext { if len(values) == 0 { return nil @@ -1452,29 +1375,6 @@ func behaviorContextPointerFromMap(values map[string]any) *BehaviorContext { } } -func (c *BehaviorContext) protocolMap() map[string]any { - if c == nil { - return nil - } - params := map[string]any{} - if c.LastAction != "" { - params["last_action"] = c.LastAction - } - if c.DwellMillis != 0 { - params["dwell_millis"] = c.DwellMillis - } - if c.CopyCount != 0 { - params["copy_count"] = c.CopyCount - } - if c.WindowSwitchCount != 0 { - params["window_switch_count"] = c.WindowSwitchCount - } - if c.PageSwitchCount != 0 { - params["page_switch_count"] = c.PageSwitchCount - } - return params -} - func selectionContextPointerFromMap(values map[string]any) *SelectionContext { if len(values) == 0 { return nil @@ -1482,13 +1382,6 @@ func selectionContextPointerFromMap(values map[string]any) *SelectionContext { return &SelectionContext{Text: stringValue(values, "text", "")} } -func (c *SelectionContext) protocolMap() map[string]any { - if c == nil || c.Text == "" { - return nil - } - return map[string]any{"text": c.Text} -} - func errorContextPointerFromMap(values map[string]any) *ErrorContext { if len(values) == 0 { return nil @@ -1496,13 +1389,6 @@ func errorContextPointerFromMap(values map[string]any) *ErrorContext { return &ErrorContext{Message: stringValue(values, "message", "")} } -func (c *ErrorContext) protocolMap() map[string]any { - if c == nil || c.Message == "" { - return nil - } - return map[string]any{"message": c.Message} -} - func clipboardContextPointerFromMap(values map[string]any) *ClipboardContext { if len(values) == 0 { return nil @@ -1510,13 +1396,6 @@ func clipboardContextPointerFromMap(values map[string]any) *ClipboardContext { return &ClipboardContext{Text: stringValue(values, "text", "")} } -func (c *ClipboardContext) protocolMap() map[string]any { - if c == nil || c.Text == "" { - return nil - } - return map[string]any{"text": c.Text} -} - func inputContextPointerFromMap(values map[string]any) *InputContext { if len(values) == 0 { return nil @@ -1543,68 +1422,6 @@ func inputContextPointerFromMap(values map[string]any) *InputContext { } } -func (c *InputContext) protocolMap() map[string]any { - if c == nil { - return nil - } - params := map[string]any{} - if page := c.Page.protocolMap(); len(page) > 0 { - params["page"] = page - } - if screen := c.Screen.protocolMap(); len(screen) > 0 { - params["screen"] = screen - } - if behavior := c.Behavior.protocolMap(); len(behavior) > 0 { - params["behavior"] = behavior - } - if selection := c.Selection.protocolMap(); len(selection) > 0 { - params["selection"] = selection - } - if errValue := c.Error.protocolMap(); len(errValue) > 0 { - params["error"] = errValue - } - if clipboard := c.Clipboard.protocolMap(); len(clipboard) > 0 { - params["clipboard"] = clipboard - } - if c.Text != "" { - params["text"] = c.Text - } - if c.SelectionText != "" { - params["selection_text"] = c.SelectionText - } - if len(c.Files) > 0 { - params["files"] = append([]string(nil), c.Files...) - } - if len(c.FilePaths) > 0 { - params["file_paths"] = append([]string(nil), c.FilePaths...) - } - if c.ScreenSummary != "" { - params["screen_summary"] = c.ScreenSummary - } - if c.ClipboardText != "" { - params["clipboard_text"] = c.ClipboardText - } - if c.HoverTarget != "" { - params["hover_target"] = c.HoverTarget - } - if c.LastAction != "" { - params["last_action"] = c.LastAction - } - if c.DwellMillis != 0 { - params["dwell_millis"] = c.DwellMillis - } - if c.CopyCount != 0 { - params["copy_count"] = c.CopyCount - } - if c.WindowSwitchCount != 0 { - params["window_switch_count"] = c.WindowSwitchCount - } - if c.PageSwitchCount != 0 { - params["page_switch_count"] = c.PageSwitchCount - } - return params -} - func voiceMetaPointerFromMap(values map[string]any) *VoiceMeta { if len(values) == 0 { return nil @@ -1617,26 +1434,6 @@ func voiceMetaPointerFromMap(values map[string]any) *VoiceMeta { } } -func (m *VoiceMeta) protocolMap() map[string]any { - if m == nil { - return nil - } - params := map[string]any{} - if m.VoiceSessionID != "" { - params["voice_session_id"] = m.VoiceSessionID - } - if m.IsLockedSession { - params["is_locked_session"] = true - } - if m.ASRConfidence != 0 { - params["asr_confidence"] = m.ASRConfidence - } - if m.SegmentID != "" { - params["segment_id"] = m.SegmentID - } - return params -} - func inputSubmitInputFromMap(values map[string]any) InputSubmitInput { return InputSubmitInput{ Type: stringValue(values, "type", ""), @@ -1645,20 +1442,6 @@ func inputSubmitInputFromMap(values map[string]any) InputSubmitInput { } } -func (i InputSubmitInput) protocolMap() map[string]any { - params := map[string]any{} - if i.Type != "" { - params["type"] = i.Type - } - if i.Text != "" { - params["text"] = i.Text - } - if i.InputMode != "" { - params["input_mode"] = i.InputMode - } - return params -} - func inputSubmitOptionsPointerFromMap(values map[string]any) *InputSubmitOptions { if len(values) == 0 { return nil @@ -1669,20 +1452,6 @@ func inputSubmitOptionsPointerFromMap(values map[string]any) *InputSubmitOptions } } -func (o *InputSubmitOptions) protocolMap() map[string]any { - if o == nil { - return nil - } - params := map[string]any{} - if o.ConfirmRequired { - params["confirm_required"] = true - } - if o.PreferredDelivery != "" { - params["preferred_delivery"] = o.PreferredDelivery - } - return params -} - func taskStartInputFromMap(values map[string]any) TaskStartInput { return TaskStartInput{ Type: stringValue(values, "type", ""), @@ -1693,26 +1462,6 @@ func taskStartInputFromMap(values map[string]any) TaskStartInput { } } -func (i TaskStartInput) protocolMap() map[string]any { - params := map[string]any{} - if i.Type != "" { - params["type"] = i.Type - } - if i.Text != "" { - params["text"] = i.Text - } - if len(i.Files) > 0 { - params["files"] = append([]string(nil), i.Files...) - } - if pageContext := i.PageContext.protocolMap(); len(pageContext) > 0 { - params["page_context"] = pageContext - } - if i.ErrorMessage != "" { - params["error_message"] = i.ErrorMessage - } - return params -} - func deliveryPreferencePointerFromMap(values map[string]any) *DeliveryPreference { if len(values) == 0 { return nil @@ -1723,20 +1472,6 @@ func deliveryPreferencePointerFromMap(values map[string]any) *DeliveryPreference } } -func (p *DeliveryPreference) protocolMap() map[string]any { - if p == nil { - return nil - } - params := map[string]any{} - if p.Preferred != "" { - params["preferred"] = p.Preferred - } - if p.Fallback != "" { - params["fallback"] = p.Fallback - } - return params -} - func taskStartOptionsPointerFromMap(values map[string]any) *TaskStartOptions { if len(values) == 0 { return nil @@ -1746,16 +1481,6 @@ func taskStartOptionsPointerFromMap(values map[string]any) *TaskStartOptions { } } -func (o *TaskStartOptions) protocolMap() map[string]any { - if o == nil { - return nil - } - if !o.ConfirmRequired { - return map[string]any{} - } - return map[string]any{"confirm_required": true} -} - func floatValue(values map[string]any, key string, fallback float64) float64 { rawValue, ok := values[key] if !ok { @@ -1777,7 +1502,7 @@ func floatValue(values map[string]any, key string, fallback float64) float64 { } } -func responseDTOToProtocolMap(value any) map[string]any { +func protocolMapFromDTO(value any) map[string]any { result, ok := protocolValueFromReflect(reflect.ValueOf(value)).(map[string]any) if !ok || result == nil { return map[string]any{} diff --git a/services/local-service/internal/orchestrator/screen_analyze.go b/services/local-service/internal/orchestrator/screen_analyze.go index 3387e3db1..ddf51b978 100644 --- a/services/local-service/internal/orchestrator/screen_analyze.go +++ b/services/local-service/internal/orchestrator/screen_analyze.go @@ -14,6 +14,28 @@ import ( "github.com/cialloclaw/cialloclaw/services/local-service/internal/tools" ) +type screenIntentDTO struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments,omitempty"` +} + +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) (map[string]any, bool, error) { if stringValue(explicitIntent, "name", "") != "screen_analyze" || s.executor == nil || !s.executor.ScreenCapabilitySnapshot().Available { return nil, false, nil @@ -79,10 +101,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. @@ -145,7 +164,7 @@ func (s *Service) resolveScreenAnalyzeIntent(snapshot taskcontext.TaskContextSna 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" @@ -277,7 +296,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() @@ -292,32 +311,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") @@ -335,6 +351,14 @@ 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} + 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 index 6677ae301..3bfa37159 100644 --- a/services/local-service/internal/orchestrator/screen_approval_state.go +++ b/services/local-service/internal/orchestrator/screen_approval_state.go @@ -48,55 +48,25 @@ func newScreenAnalysisApprovalState(approvalRequest map[string]any, pendingExecu } func (state screenAnalysisApprovalState) approvalRequestMap() map[string]any { - return map[string]any{ - "approval_id": state.ApprovalRequest.ApprovalID, - "task_id": state.ApprovalRequest.TaskID, - "operation_name": state.ApprovalRequest.OperationName, - "risk_level": state.ApprovalRequest.RiskLevel, - "target_object": state.ApprovalRequest.TargetObject, - "reason": state.ApprovalRequest.Reason, - "status": state.ApprovalRequest.Status, - "created_at": state.ApprovalRequest.CreatedAt, - } + return protocolMapFromDTO(state.ApprovalRequest) } func (state screenAnalysisApprovalState) pendingExecutionMap() map[string]any { - return map[string]any{ - "kind": state.PendingExecution.Kind, - "operation_name": state.PendingExecution.OperationName, - "source_path": state.PendingExecution.SourcePath, - "capture_mode": state.PendingExecution.CaptureMode, - "source": state.PendingExecution.Source, - "target_object": state.PendingExecution.TargetObject, - "language": state.PendingExecution.Language, - "evidence_role": state.PendingExecution.EvidenceRole, - "delivery_type": state.PendingExecution.DeliveryType, - "result_title": state.PendingExecution.ResultTitle, - "preview_text": state.PendingExecution.PreviewText, - "impact_scope": state.PendingExecution.ImpactScope.mapValue(), - } + return protocolMapFromDTO(state.PendingExecution) } func (state screenAnalysisApprovalState) bubbleMessageMap() map[string]any { - return map[string]any{ - "bubble_id": state.BubbleMessage.BubbleID, - "task_id": state.BubbleMessage.TaskID, - "type": state.BubbleMessage.Type, - "text": state.BubbleMessage.Text, - "pinned": state.BubbleMessage.Pinned, - "hidden": state.BubbleMessage.Hidden, - "created_at": state.BubbleMessage.CreatedAt, - } + return protocolMapFromDTO(state.BubbleMessage) } func (scope screenAnalysisImpactScope) mapValue() map[string]any { - return map[string]any{ - "files": cloneScreenAnalysisStrings(scope.Files), - "webpages": cloneScreenAnalysisStrings(scope.Webpages), - "apps": cloneScreenAnalysisStrings(scope.Apps), - "out_of_workspace": scope.OutOfWorkspace, - "overwrite_or_delete_risk": scope.OverwriteOrDeleteRisk, - } + 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 { diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index cabf2e3a6..313d35863 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -432,25 +432,3 @@ func invalidParamsError(detail string) *rpcError { TraceID: "trace_rpc_params", } } - -func protocolParamsMap(value any) (map[string]any, *rpcError) { - payload, err := json.Marshal(value) - if err != nil { - return nil, &rpcError{ - Code: errInvalidParams, - Message: "INVALID_PARAMS", - Detail: "params could not be normalized", - TraceID: "trace_rpc_params", - } - } - 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 params, nil -} From f622124d58358b20aa3bf88a2735fdb4dab17a15 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 16:57:48 +0000 Subject: [PATCH 27/41] test(local-service): use typed task entry helpers Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/orchestrator/protocol_dto_test_helpers_test.go | 6 +++--- .../local-service/internal/rpc/rpc_test_helpers_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 029c71ba0..b3002eaaf 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go @@ -1,7 +1,7 @@ package orchestrator func startTaskForTest(s *Service, params map[string]any) (map[string]any, error) { - response, err := s.StartTaskFromParams(StartTaskRequestFromParams(params).ProtocolParamsMap()) + response, err := s.StartTask(StartTaskRequestFromParams(params)) if err != nil { return nil, err } @@ -9,7 +9,7 @@ func startTaskForTest(s *Service, params map[string]any) (map[string]any, error) } func submitInputForTest(s *Service, params map[string]any) (map[string]any, error) { - response, err := s.SubmitInputFromParams(SubmitInputRequestFromParams(params).ProtocolParamsMap()) + response, err := s.SubmitInput(SubmitInputRequestFromParams(params)) if err != nil { return nil, err } @@ -17,7 +17,7 @@ func submitInputForTest(s *Service, params map[string]any) (map[string]any, erro } func taskDetailGetForTest(s *Service, params map[string]any) (map[string]any, error) { - response, err := s.TaskDetailGetFromParams(TaskDetailGetRequestFromParams(params).ProtocolParamsMap()) + response, err := s.TaskDetailGet(TaskDetailGetRequestFromParams(params)) if err != nil { return nil, err } 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 3e93be2aa..51d073455 100644 --- a/services/local-service/internal/rpc/rpc_test_helpers_test.go +++ b/services/local-service/internal/rpc/rpc_test_helpers_test.go @@ -215,7 +215,7 @@ func rpcRequestMeta(traceID string) map[string]any { } func startTaskForTest(s *orchestrator.Service, params map[string]any) (map[string]any, error) { - response, err := s.StartTaskFromParams(orchestrator.StartTaskRequestFromParams(params).ProtocolParamsMap()) + response, err := s.StartTask(orchestrator.StartTaskRequestFromParams(params)) if err != nil { return nil, err } From c7c21189f57b7cc855a368f412e1dbf8602affc8 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 17:11:07 +0000 Subject: [PATCH 28/41] test(local-service): make typed entry helpers explicit Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../protocol_dto_test_helpers_test.go | 12 +- .../internal/orchestrator/service_test.go | 714 +++++++++--------- 2 files changed, 363 insertions(+), 363 deletions(-) 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 index b3002eaaf..258dd590e 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test_helpers_test.go @@ -1,23 +1,23 @@ package orchestrator -func startTaskForTest(s *Service, params map[string]any) (map[string]any, error) { - response, err := s.StartTask(StartTaskRequestFromParams(params)) +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, params map[string]any) (map[string]any, error) { - response, err := s.SubmitInput(SubmitInputRequestFromParams(params)) +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, params map[string]any) (map[string]any, error) { - response, err := s.TaskDetailGet(TaskDetailGetRequestFromParams(params)) +func taskDetailGetForTest(s *Service, request TaskDetailGetRequest) (map[string]any, error) { + response, err := s.TaskDetailGet(request) if err != nil { return nil, err } diff --git a/services/local-service/internal/orchestrator/service_test.go b/services/local-service/internal/orchestrator/service_test.go index 664b341a9..0d0e56b4c 100644 --- a/services/local-service/internal/orchestrator/service_test.go +++ b/services/local-service/internal/orchestrator/service_test.go @@ -1127,7 +1127,7 @@ func TestServiceStartTaskAndConfirmFlow(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -1135,7 +1135,7 @@ func TestServiceStartTaskAndConfirmFlow(t *testing.T) { "type": "text_selection", "text": "这里是一段需要解释的内容", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -1190,7 +1190,7 @@ func TestServiceSubmitInputReturnsSocialChatWithoutTask(t *testing.T) { testCases := []string{"你好", "🙂"} for index, testCase := range testCases { t.Run(testCase, func(t *testing.T) { - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": fmt.Sprintf("sess_short_text_%02d", index), "source": "floating_ball", "trigger": "hover_text_input", @@ -1198,7 +1198,7 @@ func TestServiceSubmitInputReturnsSocialChatWithoutTask(t *testing.T) { "type": "text", "text": testCase, }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1228,7 +1228,7 @@ func TestServiceSubmitInputRoutesUnanchoredAmbiguousTextToConfirmation(t *testin output: `{"route":"clarification_needed","reply":""}`, }) - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_ambiguous_text", "source": "floating_ball", "trigger": "hover_text_input", @@ -1236,7 +1236,7 @@ func TestServiceSubmitInputRoutesUnanchoredAmbiguousTextToConfirmation(t *testin "type": "text", "text": "帮我看下", }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1270,7 +1270,7 @@ func TestServiceSubmitInputKeepsTaskRequestAfterClassifier(t *testing.T) { }, }) - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_classified_task", "source": "floating_ball", "trigger": "hover_text_input", @@ -1278,7 +1278,7 @@ func TestServiceSubmitInputKeepsTaskRequestAfterClassifier(t *testing.T) { "type": "text", "text": "Translate this note into English", }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1311,7 +1311,7 @@ func TestServiceSubmitInputFallsBackToTaskWhenClassifierFails(t *testing.T) { }, }) - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_classifier_fallback", "source": "floating_ball", "trigger": "hover_text_input", @@ -1319,7 +1319,7 @@ func TestServiceSubmitInputFallsBackToTaskWhenClassifierFails(t *testing.T) { "type": "text", "text": "Summarize the visible note", }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1336,7 +1336,7 @@ func TestServiceSubmitInputFallsBackToTaskWhenClassifierFails(t *testing.T) { func TestServiceSubmitInputRespectsExplicitConfirmationForFreeText(t *testing.T) { service := newTestService() - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_confirm_free_text", "source": "floating_ball", "trigger": "hover_text_input", @@ -1347,7 +1347,7 @@ func TestServiceSubmitInputRespectsExplicitConfirmationForFreeText(t *testing.T) "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1368,7 +1368,7 @@ func TestServiceSubmitInputRespectsExplicitConfirmationForFreeText(t *testing.T) func TestServiceSubmitInputRoutesClearCommandToAgentLoopWithoutForcedConfirmation(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Translated note ready.") - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_clear_command", "source": "floating_ball", "trigger": "hover_text_input", @@ -1376,7 +1376,7 @@ func TestServiceSubmitInputRoutesClearCommandToAgentLoopWithoutForcedConfirmatio "type": "text", "text": "Translate this note into English", }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -1401,7 +1401,7 @@ func TestServiceSubmitInputRoutesClearCommandToAgentLoopWithoutForcedConfirmatio func TestServiceSubmitInputUsesSuggestedWorkspaceDeliveryForLongAgentLoopInput(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "Long-form result body.") - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_long_command", "source": "floating_ball", "trigger": "hover_text_input", @@ -1409,7 +1409,7 @@ func TestServiceSubmitInputUsesSuggestedWorkspaceDeliveryForLongAgentLoopInput(t "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) } @@ -1438,7 +1438,7 @@ func TestServiceStartTaskFailsAfterExecutionTimeout(t *testing.T) { }) service.executionTimeout = 20 * time.Millisecond - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_execution_timeout", "source": "floating_ball", "trigger": "hover_text_input", @@ -1450,7 +1450,7 @@ func TestServiceStartTaskFailsAfterExecutionTimeout(t *testing.T) { "name": "rewrite", "arguments": map[string]any{}, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -1510,7 +1510,7 @@ func TestServiceSubmitInputWorkspaceDeliverySkipsShortBubbleTimeout(t *testing.T }) service.executionTimeout = 20 * time.Millisecond - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_long_command_timeout", "source": "floating_ball", "trigger": "hover_text_input", @@ -1518,7 +1518,7 @@ func TestServiceSubmitInputWorkspaceDeliverySkipsShortBubbleTimeout(t *testing.T "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) } @@ -1544,7 +1544,7 @@ func TestServiceSubmitInputWorkspaceDeliverySkipsShortBubbleTimeout(t *testing.T func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued task output.") - firstResult, err := startTaskForTest(service, map[string]any{ + firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_serial", "source": "floating_ball", "trigger": "hover_text_input", @@ -1559,7 +1559,7 @@ func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *tes "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("first start task failed: %v", err) } @@ -1567,7 +1567,7 @@ func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *tes t.Fatalf("expected first task to wait for authorization, got %+v", firstResult["task"]) } - secondResult, err := submitInputForTest(service, map[string]any{ + secondResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_serial", "source": "floating_ball", "trigger": "hover_text_input", @@ -1575,7 +1575,7 @@ func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *tes "type": "text", "text": "Translate this note into English", }, - }) + })) if err != nil { t.Fatalf("second submit input failed: %v", err) } @@ -1595,7 +1595,7 @@ func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *tes func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued confirm output.") - firstResult, err := startTaskForTest(service, map[string]any{ + firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_confirm_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1610,7 +1610,7 @@ func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("first start task failed: %v", err) } @@ -1618,7 +1618,7 @@ func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T t.Fatalf("expected first task to wait for authorization, got %+v", firstResult["task"]) } - secondResult, err := submitInputForTest(service, map[string]any{ + secondResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_confirm_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1629,7 +1629,7 @@ func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("second submit input failed: %v", err) } @@ -1658,7 +1658,7 @@ func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued cancel output.") - firstResult, err := startTaskForTest(service, map[string]any{ + firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_cancel_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1673,13 +1673,13 @@ func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *test "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 := submitInputForTest(service, map[string]any{ + secondResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_cancel_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1687,13 +1687,13 @@ func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *test "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 := submitInputForTest(service, map[string]any{ + thirdResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_cancel_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1701,7 +1701,7 @@ func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *test "type": "text", "text": "Summarize this release note for me", }, - }) + })) if err != nil { t.Fatalf("third submit input failed: %v", err) } @@ -1731,7 +1731,7 @@ func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *test func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Snapshot resume output.") - firstResult, err := startTaskForTest(service, map[string]any{ + firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_snapshot_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1746,13 +1746,13 @@ func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing. "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 := startTaskForTest(service, map[string]any{ + secondResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_snapshot_queue", "source": "floating_ball", "trigger": "text_selected_click", @@ -1775,7 +1775,7 @@ func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing. "name": "agent_loop", "arguments": map[string]any{}, }, - }) + })) if err != nil { t.Fatalf("second start task failed: %v", err) } @@ -1811,7 +1811,7 @@ func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing. func TestServiceSecurityRespondResumesQueuedSessionTask(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued task resumed output.") - firstResult, err := startTaskForTest(service, map[string]any{ + firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_resume_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1826,13 +1826,13 @@ func TestServiceSecurityRespondResumesQueuedSessionTask(t *testing.T) { "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 := submitInputForTest(service, map[string]any{ + secondResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_resume_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1840,7 +1840,7 @@ func TestServiceSecurityRespondResumesQueuedSessionTask(t *testing.T) { "type": "text", "text": "Translate this note into English", }, - }) + })) if err != nil { t.Fatalf("second submit input failed: %v", err) } @@ -1877,7 +1877,7 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * t.Fatalf("write screen input failed: %v", err) } - firstResult, err := startTaskForTest(service, map[string]any{ + firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1892,13 +1892,13 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * "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 := startTaskForTest(service, map[string]any{ + secondResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_queue", "source": "floating_ball", "trigger": "hover_text_input", @@ -1912,7 +1912,7 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * "path": "inputs/screen.png", }, }, - }) + })) if err != nil { t.Fatalf("second start task failed: %v", err) } @@ -1953,7 +1953,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 := taskDetailGetForTest(service, map[string]any{"task_id": secondTaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": secondTaskID})) if err != nil { t.Fatalf("task detail get for queued screen task failed: %v", err) } @@ -1984,7 +1984,7 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * func TestServiceConfirmTaskRunsStoredAgentLoopIntentWithoutCorrection(t *testing.T) { service, _ := newTestServiceWithModelClient(t, &stubToolCallingModelClient{}) - startResult, err := submitInputForTest(service, map[string]any{ + startResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_unknown_confirm", "source": "floating_ball", "trigger": "hover_text_input", @@ -1995,7 +1995,7 @@ func TestServiceConfirmTaskRunsStoredAgentLoopIntentWithoutCorrection(t *testing "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -2101,7 +2101,7 @@ func TestExecuteTaskResultPageWaitingInputDoesNotClaimDeliveryInTrace(t *testing func TestServiceConfirmTaskKeepsUnknownIntentInConfirmationWhenRejected(t *testing.T) { service := newTestService() - startResult, err := submitInputForTest(service, map[string]any{ + startResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_unknown_cancel", "source": "floating_ball", "trigger": "hover_text_input", @@ -2112,7 +2112,7 @@ func TestServiceConfirmTaskKeepsUnknownIntentInConfirmationWhenRejected(t *testi "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -2148,7 +2148,7 @@ func TestServiceConfirmTaskKeepsUnknownIntentInConfirmationWhenRejected(t *testi func TestServiceConfirmTaskRewritesPlaceholderTitleAfterCorrection(t *testing.T) { service := newTestService() - startResult, err := submitInputForTest(service, map[string]any{ + startResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_unknown_title", "source": "floating_ball", "trigger": "hover_text_input", @@ -2159,7 +2159,7 @@ func TestServiceConfirmTaskRewritesPlaceholderTitleAfterCorrection(t *testing.T) "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -2186,7 +2186,7 @@ func TestServiceConfirmTaskRewritesPlaceholderTitleAfterCorrection(t *testing.T) func TestServiceConfirmTaskIgnoresCorrectedIntentWhenConfirmedTrue(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Explained content.") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_confirm_ignore_correction", "source": "floating_ball", "trigger": "text_selected_click", @@ -2194,7 +2194,7 @@ func TestServiceConfirmTaskIgnoresCorrectedIntentWhenConfirmedTrue(t *testing.T) "type": "text_selection", "text": "这里是一段需要解释的内容", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -2230,7 +2230,7 @@ func TestServiceConfirmTaskIgnoresCorrectedIntentWhenConfirmedTrue(t *testing.T) func TestServiceConfirmTaskRejectsOutOfPhaseRequest(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_confirm_out_of_phase", "source": "floating_ball", "trigger": "text_selected_click", @@ -2238,7 +2238,7 @@ func TestServiceConfirmTaskRejectsOutOfPhaseRequest(t *testing.T) { "type": "text_selection", "text": "请生成一个文件版本", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -2902,7 +2902,7 @@ func TestServiceNotepadUpdateReturnsDeletedItemID(t *testing.T) { func TestServiceExecutionAuditIDsStayUniqueAcrossToolAndTaskRecords(t *testing.T) { service, _ := newTestServiceWithExecution(t, "runtime output") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_audit", "source": "floating_ball", "trigger": "text_selected_click", @@ -2910,7 +2910,7 @@ func TestServiceExecutionAuditIDsStayUniqueAcrossToolAndTaskRecords(t *testing.T "type": "text_selection", "text": "解释一下这段内容", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -2944,7 +2944,7 @@ func TestServiceExecutionAuditIDsStayUniqueAcrossToolAndTaskRecords(t *testing.T func TestServiceRecommendationGetUsesRuntimeTaskState(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_recommend", "source": "floating_ball", "trigger": "text_selected_click", @@ -2952,7 +2952,7 @@ func TestServiceRecommendationGetUsesRuntimeTaskState(t *testing.T) { "type": "text_selection", "text": "tiny note", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -3506,7 +3506,7 @@ func TestServiceTaskControlRestartExecutesFreshAttempt(t *testing.T) { modelClient := &stubToolCallingModelClient{output: "Restarted loop output."} service, _ := newTestServiceWithModelClient(t, modelClient) - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_restart_attempt", "source": "floating_ball", "trigger": "hover_text_input", @@ -3518,7 +3518,7 @@ func TestServiceTaskControlRestartExecutesFreshAttempt(t *testing.T) { "name": "agent_loop", "arguments": map[string]any{}, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -3883,7 +3883,7 @@ func TestServiceTaskDetailGetRestartAttemptHidesPreviousRunFormalObjects(t *test t.Fatalf("expected restart to allocate a fresh processing attempt, got %+v", restartedTask) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": task.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": task.TaskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -3959,7 +3959,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 := taskDetailGetForTest(service, map[string]any{"task_id": task.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": task.TaskID})) if err != nil { t.Fatalf("task detail get during restart preparation failed: %v", err) } @@ -4163,7 +4163,7 @@ func TestServiceRecommendationFeedbackSubmitAppliesCooldown(t *testing.T) { func TestServiceSubmitInputWithFilesDoesNotWaitForInput(t *testing.T) { service := newTestService() - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_files", "source": "floating_ball", "input": map[string]any{}, @@ -4174,7 +4174,7 @@ func TestServiceSubmitInputWithFilesDoesNotWaitForInput(t *testing.T) { "app_name": "desktop", }, }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -4206,7 +4206,7 @@ func TestServiceSubmitInputEmptyTextReturnsWaitingInput(t *testing.T) { t.Fatalf("new service: %v", err) } - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -4216,7 +4216,7 @@ func TestServiceSubmitInputEmptyTextReturnsWaitingInput(t *testing.T) { "input_mode": "text", }, "context": map[string]any{}, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -4271,7 +4271,7 @@ func TestServiceDirectStartBuildsMemoryAndDeliveryHandoffs(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -4285,7 +4285,7 @@ func TestServiceDirectStartBuildsMemoryAndDeliveryHandoffs(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4326,7 +4326,7 @@ func TestServiceDirectStartBuildsMemoryAndDeliveryHandoffs(t *testing.T) { func TestServiceStartTaskRespectsPreferredDelivery(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -4344,7 +4344,7 @@ func TestServiceStartTaskRespectsPreferredDelivery(t *testing.T) { "preferred": "bubble", "fallback": "workspace_document", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4376,7 +4376,7 @@ func TestServiceStartTaskRespectsPreferredDelivery(t *testing.T) { func TestServiceStartTaskFileInstructionSkipsForcedIntentConfirmation(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Attachment summary ready.") - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_file_instruction", "source": "floating_ball", "trigger": "file_drop", @@ -4389,7 +4389,7 @@ func TestServiceStartTaskFileInstructionSkipsForcedIntentConfirmation(t *testing "preferred": "bubble", "fallback": "task_detail", }, - }) + })) if err != nil { t.Fatalf("start file task failed: %v", err) } @@ -4412,7 +4412,7 @@ func TestServiceStartTaskFileInstructionSkipsForcedIntentConfirmation(t *testing func TestServiceStartTaskFileWithoutInstructionStillRequiresConfirmation(t *testing.T) { service := newTestService() - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_file_needs_goal", "source": "floating_ball", "trigger": "file_drop", @@ -4423,7 +4423,7 @@ func TestServiceStartTaskFileWithoutInstructionStillRequiresConfirmation(t *test "options": map[string]any{ "confirm_required": false, }, - }) + })) if err != nil { t.Fatalf("start file task failed: %v", err) } @@ -4454,7 +4454,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { t.Fatalf("write source file: %v", err) } - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_read_file_sample", "source": "floating_ball", "trigger": "hover_text_input", @@ -4468,7 +4468,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { "path": "notes/source.txt", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4524,7 +4524,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { t.Fatalf("expected persisted direct delivery result, ok=%v record=%+v", ok, deliveryRecord) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -4541,7 +4541,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { func TestServiceSubmitInputRespectsPreferredDelivery(t *testing.T) { service := newTestService() - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -4553,7 +4553,7 @@ func TestServiceSubmitInputRespectsPreferredDelivery(t *testing.T) { "confirm_required": false, "preferred_delivery": "workspace_document", }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -4587,7 +4587,7 @@ func TestServiceSubmitInputRespectsPreferredDelivery(t *testing.T) { func TestServiceConfirmTaskRespectsStoredPreferredDelivery(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -4598,7 +4598,7 @@ func TestServiceConfirmTaskRespectsStoredPreferredDelivery(t *testing.T) { "delivery": map[string]any{ "preferred": "workspace_document", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4648,7 +4648,7 @@ func TestServiceStartTaskWaitingAuthDoesNotSetFinishedAt(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "file_drop", @@ -4663,7 +4663,7 @@ func TestServiceStartTaskWaitingAuthDoesNotSetFinishedAt(t *testing.T) { "target_path": "workspace_document", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4704,7 +4704,7 @@ func TestServiceConfirmCanEnterWaitingAuth(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -4712,7 +4712,7 @@ func TestServiceConfirmCanEnterWaitingAuth(t *testing.T) { "type": "text_selection", "text": "这里是一段需要确认处理方式的内容", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4771,7 +4771,7 @@ func TestServiceConfirmWaitingAuthPersistsApprovalRequestRecord(t *testing.T) { t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_approval_store", "source": "floating_ball", "trigger": "text_selected_click", @@ -4779,7 +4779,7 @@ func TestServiceConfirmWaitingAuthPersistsApprovalRequestRecord(t *testing.T) { "type": "text_selection", "text": "persist approval request before execution", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4824,7 +4824,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 := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_approval_store_failure", "source": "floating_ball", "trigger": "text_selected_click", @@ -4832,7 +4832,7 @@ func TestServiceConfirmTaskReturnsStorageErrorWhenApprovalPersistenceFails(t *te "type": "text_selection", "text": "persist approval request before execution", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4872,7 +4872,7 @@ func TestServiceSecurityRespondAllowOnceResumesAndCompletes(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -4880,7 +4880,7 @@ func TestServiceSecurityRespondAllowOnceResumesAndCompletes(t *testing.T) { "type": "text_selection", "text": "需要授权后继续执行的内容", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -4965,7 +4965,7 @@ func TestServiceSecurityRespondAllowOnceResumesAndCompletes(t *testing.T) { func TestServiceSecurityRespondRespectsFallbackDelivery(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -4977,7 +4977,7 @@ func TestServiceSecurityRespondRespectsFallbackDelivery(t *testing.T) { "preferred": "unsupported_delivery", "fallback": "bubble", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5036,7 +5036,7 @@ func TestServiceSecurityRespondDenyOnceCancelsTask(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -5044,7 +5044,7 @@ func TestServiceSecurityRespondDenyOnceCancelsTask(t *testing.T) { "type": "text_selection", "text": "需要授权后继续执行的内容", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5101,7 +5101,7 @@ func TestServiceSecurityRespondPersistsAuthorizationRecord(t *testing.T) { t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_authorization_store", "source": "floating_ball", "trigger": "text_selected_click", @@ -5109,7 +5109,7 @@ func TestServiceSecurityRespondPersistsAuthorizationRecord(t *testing.T) { "type": "text_selection", "text": "persist authorization decision after approval", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5171,7 +5171,7 @@ func TestServiceSecurityRespondReturnsStorageErrorWhenAuthorizationPersistenceFa originalStore := service.storage.AuthorizationRecordStore() defer replaceAuthorizationRecordStore(t, service.storage, originalStore) - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_authorization_store_failure", "source": "floating_ball", "trigger": "text_selected_click", @@ -5179,7 +5179,7 @@ func TestServiceSecurityRespondReturnsStorageErrorWhenAuthorizationPersistenceFa "type": "text_selection", "text": "persist authorization decision after approval", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5225,7 +5225,7 @@ func TestServiceSecurityRespondRejectsOutOfPhaseAuthorizationPersistence(t *test t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_authorization_out_of_phase", "source": "floating_ball", "trigger": "hover_text_input", @@ -5239,7 +5239,7 @@ func TestServiceSecurityRespondRejectsOutOfPhaseAuthorizationPersistence(t *test "target_path": "notes/output.md", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5336,7 +5336,7 @@ func TestServiceSecurityRespondRejectsUnsupportedDecision(t *testing.T) { t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_unsupported_approval_decision", "source": "floating_ball", "trigger": "hover_text_input", @@ -5350,7 +5350,7 @@ func TestServiceSecurityRespondRejectsUnsupportedDecision(t *testing.T) { "target_path": "notes/output.md", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5393,7 +5393,7 @@ func TestServiceSecurityRespondKeepsAuthorizationHistoryAcrossMultipleCycles(t * t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_authorization_history", "source": "floating_ball", "trigger": "hover_text_input", @@ -5407,7 +5407,7 @@ func TestServiceSecurityRespondKeepsAuthorizationHistoryAcrossMultipleCycles(t * "target_path": "notes/output.md", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5464,7 +5464,7 @@ func TestServiceStartTaskWriteFileOverwriteWaitsForApproval(t *testing.T) { t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_overwrite", "source": "floating_ball", "trigger": "hover_text_input", @@ -5478,7 +5478,7 @@ func TestServiceStartTaskWriteFileOverwriteWaitsForApproval(t *testing.T) { "target_path": "notes/output.md", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5502,7 +5502,7 @@ func TestServiceStartTaskWriteFileOverwriteWaitsForApproval(t *testing.T) { func TestServiceStartTaskExecCommandWaitsForApproval(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "unused") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec_cmd", "source": "floating_ball", "trigger": "hover_text_input", @@ -5517,7 +5517,7 @@ func TestServiceStartTaskExecCommandWaitsForApproval(t *testing.T) { "args": []any{"/c", "echo", "ok"}, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5541,7 +5541,7 @@ func TestServiceStartTaskExecCommandWaitsForApproval(t *testing.T) { func TestServiceStartTaskOutOfWorkspaceWriteIsBlocked(t *testing.T) { service, _ := newTestServiceWithExecution(t, "unused") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_outside", "source": "floating_ball", "trigger": "hover_text_input", @@ -5555,7 +5555,7 @@ func TestServiceStartTaskOutOfWorkspaceWriteIsBlocked(t *testing.T) { "target_path": "../secret.txt", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5578,7 +5578,7 @@ func TestServiceStartTaskOutOfWorkspaceWriteIsBlocked(t *testing.T) { func TestServiceSecurityRespondAllowOnceReturnsStructuredExecutionFailure(t *testing.T) { service, _ := newTestServiceWithExecutionOptions(t, "unused", failingExecutionBackend{err: errors.New("runner unavailable")}, nil) - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec_fail", "source": "floating_ball", "trigger": "hover_text_input", @@ -5593,7 +5593,7 @@ func TestServiceSecurityRespondAllowOnceReturnsStructuredExecutionFailure(t *tes "args": []any{"/c", "echo", "ok"}, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5640,7 +5640,7 @@ func TestServiceSecurityRespondAllowOnceExecCommandCompletesAfterApproval(t *tes t.Fatalf("mkdir workspace root: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec_allow", "source": "floating_ball", "trigger": "hover_text_input", @@ -5655,7 +5655,7 @@ func TestServiceSecurityRespondAllowOnceExecCommandCompletesAfterApproval(t *tes "args": []any{"/c", "echo", "ok"}, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5684,7 +5684,7 @@ func TestServiceSecurityRespondAllowOnceExecCommandCompletesAfterApproval(t *tes func TestServiceSecurityRespondAllowOnceCompletesDerivedWriteFileAfterApproval(t *testing.T) { service, _ := newTestServiceWithExecution(t, "新的文档内容") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_derived_write", "source": "floating_ball", "trigger": "hover_text_input", @@ -5699,7 +5699,7 @@ func TestServiceSecurityRespondAllowOnceCompletesDerivedWriteFileAfterApproval(t "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5731,7 +5731,7 @@ func TestServiceSecurityRespondAllowOnceReturnsStructuredRecoveryFailure(t *test t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_recovery_fail", "source": "floating_ball", "trigger": "hover_text_input", @@ -5745,7 +5745,7 @@ func TestServiceSecurityRespondAllowOnceReturnsStructuredRecoveryFailure(t *test "target_path": "notes/output.md", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5793,7 +5793,7 @@ func TestServiceTaskListSupportsSortParams(t *testing.T) { return current }) - firstResult, err := startTaskForTest(service, map[string]any{ + firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -5807,12 +5807,12 @@ func TestServiceTaskListSupportsSortParams(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start first task failed: %v", err) } - secondResult, err := startTaskForTest(service, map[string]any{ + secondResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -5826,7 +5826,7 @@ func TestServiceTaskListSupportsSortParams(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start second task failed: %v", err) } @@ -6229,7 +6229,7 @@ func TestServiceTaskListUsesStructuredStoreUnlimitedPaginationInMemoryFallback(t func TestServiceTaskListMergesStructuredStorageWhenOffsetExceedsRuntimePage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "runtime paging") - _, err := startTaskForTest(service, map[string]any{ + _, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_page", "source": "floating_ball", "trigger": "hover_text_input", @@ -6243,7 +6243,7 @@ func TestServiceTaskListMergesStructuredStorageWhenOffsetExceedsRuntimePage(t *t "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start runtime task failed: %v", err) } @@ -6292,7 +6292,7 @@ func TestServiceTaskListClampsPagingParams(t *testing.T) { service := newTestService() for index := 0; index < 25; index++ { - _, err := startTaskForTest(service, map[string]any{ + _, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": fmt.Sprintf("sess_clamp_%02d", index), "source": "floating_ball", "trigger": "hover_text_input", @@ -6306,7 +6306,7 @@ func TestServiceTaskListClampsPagingParams(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task %d failed: %v", index, err) } @@ -6469,7 +6469,7 @@ func TestServiceTaskListFallbackMatchesRuntimeSortTieBreaker(t *testing.T) { func TestServiceDashboardOverviewUsesRuntimeAggregation(t *testing.T) { service := newTestService() - completedResult, err := startTaskForTest(service, map[string]any{ + completedResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -6483,12 +6483,12 @@ func TestServiceDashboardOverviewUsesRuntimeAggregation(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start completed task failed: %v", err) } - waitingResult, err := startTaskForTest(service, map[string]any{ + waitingResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -6502,7 +6502,7 @@ func TestServiceDashboardOverviewUsesRuntimeAggregation(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start waiting auth task failed: %v", err) } @@ -6652,7 +6652,7 @@ func TestIsWorkspaceRelativePathKeepsArtifactsInsideWorkspaceScope(t *testing.T) func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_detail", "source": "floating_ball", "trigger": "hover_text_input", @@ -6666,13 +6666,13 @@ func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } taskID := startResult["task"].(map[string]any)["task_id"].(string) - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6700,7 +6700,7 @@ func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_detail_stale", "source": "floating_ball", "trigger": "hover_text_input", @@ -6714,7 +6714,7 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6732,7 +6732,7 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi runtimeRecord.SecuritySummary["pending_authorizations"] = 1 }) - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6749,7 +6749,7 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_detail_bad_task", "source": "floating_ball", "trigger": "hover_text_input", @@ -6763,7 +6763,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing. "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6773,7 +6773,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing. runtimeRecord.ApprovalRequest["task_id"] = "task_other" }) - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6790,7 +6790,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing. func TestServiceTaskDetailGetDropsApprovalAnchorWhenStatusIsNotPending(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_detail_bad_status", "source": "floating_ball", "trigger": "hover_text_input", @@ -6804,7 +6804,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWhenStatusIsNotPending(t *testin "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6814,7 +6814,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWhenStatusIsNotPending(t *testin runtimeRecord.ApprovalRequest["status"] = "approved" }) - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6828,7 +6828,7 @@ func TestServiceTaskDetailGetIncludesRuntimeSummary(t *testing.T) { if service.storage == nil || service.storage.LoopRuntimeStore() == nil { t.Fatal("expected loop runtime store to be wired") } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_detail_runtime_summary", "source": "floating_ball", "trigger": "hover_text_input", @@ -6842,7 +6842,7 @@ func TestServiceTaskDetailGetIncludesRuntimeSummary(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6879,7 +6879,7 @@ func TestServiceTaskDetailGetIncludesRuntimeSummary(t *testing.T) { t.Fatal("expected steering append to succeed") } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6909,7 +6909,7 @@ func TestServiceTaskDetailGetIncludesRuntimeSummary(t *testing.T) { func TestServiceTaskDetailGetKeepsRuntimeSummaryScopedToRuntimeEvents(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail runtime event scope") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_detail_runtime_scope", "source": "floating_ball", "trigger": "hover_text_input", @@ -6917,7 +6917,7 @@ func TestServiceTaskDetailGetKeepsRuntimeSummaryScopedToRuntimeEvents(t *testing "type": "text", "text": "task detail runtime summary should ignore non-runtime latest events", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -6926,7 +6926,7 @@ func TestServiceTaskDetailGetKeepsRuntimeSummaryScopedToRuntimeEvents(t *testing t.Fatal("expected steering append to succeed") } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6984,7 +6984,7 @@ func TestServiceTaskDetailGetIncludesFailureSummaryForFailedScreenTask(t *testin }, }, tools.ErrOCRWorkerFailed) - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": updatedTask.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": updatedTask.TaskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -7279,7 +7279,7 @@ func TestServiceDashboardOverviewRespectsIncludeFilter(t *testing.T) { func TestServiceDashboardOverviewFocusModeNarrowsSecondaryData(t *testing.T) { service := newTestService() - _, err := startTaskForTest(service, map[string]any{ + _, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_focus", "source": "floating_ball", "trigger": "hover_text_input", @@ -7293,12 +7293,12 @@ func TestServiceDashboardOverviewFocusModeNarrowsSecondaryData(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start completed task failed: %v", err) } - _, err = startTaskForTest(service, map[string]any{ + _, err = startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_focus", "source": "floating_ball", "trigger": "hover_text_input", @@ -7312,7 +7312,7 @@ func TestServiceDashboardOverviewFocusModeNarrowsSecondaryData(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start waiting auth task failed: %v", err) } @@ -7433,7 +7433,7 @@ func TestServiceDashboardOverviewResortsMergedRuntimeAndStoredTasks(t *testing.T t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, map[string]any{ + runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_merge_overview", "source": "floating_ball", "trigger": "hover_text_input", @@ -7447,7 +7447,7 @@ func TestServiceDashboardOverviewResortsMergedRuntimeAndStoredTasks(t *testing.T "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start runtime task failed: %v", err) } @@ -7516,7 +7516,7 @@ func TestServiceDashboardOverviewLoadsStoredTasksOncePerRequest(t *testing.T) { countingStore := &countingTaskStore{base: service.storage.TaskStore()} replaceTaskStore(t, service.storage, countingStore) - if _, err := startTaskForTest(service, map[string]any{ + if _, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_overview_count", "source": "floating_ball", "trigger": "hover_text_input", @@ -7530,7 +7530,7 @@ func TestServiceDashboardOverviewLoadsStoredTasksOncePerRequest(t *testing.T) { "style": "key_points", }, }, - }); err != nil { + })); err != nil { t.Fatalf("start runtime task failed: %v", err) } @@ -7568,7 +7568,7 @@ func TestServiceDashboardOverviewLoadsStoredTasksOncePerRequest(t *testing.T) { func TestServiceMirrorOverviewUsesRuntimeMirrorReferences(t *testing.T) { service := newTestService() - _, err := startTaskForTest(service, map[string]any{ + _, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -7582,7 +7582,7 @@ func TestServiceMirrorOverviewUsesRuntimeMirrorReferences(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -7617,7 +7617,7 @@ func TestServiceStartTaskWritesRealMemorySummary(t *testing.T) { t.Fatal("expected storage service to be wired") } - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_memory_write", "source": "floating_ball", "trigger": "hover_text_input", @@ -7631,7 +7631,7 @@ func TestServiceStartTaskWritesRealMemorySummary(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -7661,7 +7661,7 @@ 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 := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_task", "source": "floating_ball", "trigger": "hover_text_input", @@ -7675,7 +7675,7 @@ func TestServiceStartTaskHandlesControlledScreenAnalyzeIntent(t *testing.T) { "path": "inputs/screen.png", }, }, - }) + })) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -7737,7 +7737,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 := taskDetailGetForTest(service, map[string]any{"task_id": task["task_id"]}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": task["task_id"]})) if err != nil { t.Fatalf("task detail get for screen task failed: %v", err) } @@ -7907,7 +7907,7 @@ func TestServiceStartTaskPreservesClipCaptureModeThroughScreenApproval(t *testin t.Fatalf("write clip input failed: %v", err) } - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_clip_task", "source": "floating_ball", "trigger": "hover_text_input", @@ -7922,7 +7922,7 @@ func TestServiceStartTaskPreservesClipCaptureModeThroughScreenApproval(t *testin "capture_mode": string(tools.ScreenCaptureModeClip), }, }, - }) + })) if err != nil { t.Fatalf("start clip screen analyze task failed: %v", err) } @@ -7963,7 +7963,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 := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_infer_start", "source": "floating_ball", "trigger": "hover_text_input", @@ -7981,7 +7981,7 @@ func TestServiceStartTaskInfersScreenAnalyzeFromVisualErrorRequest(t *testing.T) "context": map[string]any{ "screen_summary": "release validation failed on current screen", }, - }) + })) if err != nil { t.Fatalf("start inferred screen analyze task failed: %v", err) } @@ -8051,7 +8051,7 @@ func TestServiceStartTaskExplicitScreenAnalyzeKeepsFreshAuthorizationBoundary(t }, }) - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "hover_text_input", @@ -8072,7 +8072,7 @@ func TestServiceStartTaskExplicitScreenAnalyzeKeepsFreshAuthorizationBoundary(t "path": "inputs/screen.png", }, }, - }) + })) if err != nil { t.Fatalf("start explicit screen analyze task failed: %v", err) } @@ -8125,7 +8125,7 @@ func TestServiceStartTaskHandlesClipScreenAnalyzePath(t *testing.T) { t.Fatalf("write clip source failed: %v", err) } - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_clip_start", "source": "floating_ball", "trigger": "hover_text_input", @@ -8139,7 +8139,7 @@ func TestServiceStartTaskHandlesClipScreenAnalyzePath(t *testing.T) { "path": "clips/demo.webm", }, }, - }) + })) if err != nil { t.Fatalf("start clip screen analyze task failed: %v", err) } @@ -8174,7 +8174,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 := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get for clip screen task failed: %v", err) } @@ -8198,7 +8198,7 @@ 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 := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_stop", "source": "floating_ball", "trigger": "hover_text_input", @@ -8212,7 +8212,7 @@ func TestServiceScreenAnalyzeStopsSessionAfterSuccessfulApproval(t *testing.T) { "path": "inputs/screen.png", }, }, - }) + })) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -8250,7 +8250,7 @@ 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 := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_cleanup", "source": "floating_ball", "trigger": "hover_text_input", @@ -8264,7 +8264,7 @@ func TestServiceScreenAnalyzeFailureExpiresAndCleansSession(t *testing.T) { "path": "inputs/screen.png", }, }, - }) + })) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -8300,7 +8300,7 @@ 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 := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_capture_failure", "source": "floating_ball", "trigger": "hover_text_input", @@ -8314,7 +8314,7 @@ func TestServiceScreenAnalyzeCaptureFailureExpiresAndCleansSession(t *testing.T) "path": "inputs/screen.png", }, }, - }) + })) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -8347,7 +8347,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 := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_summary_title", "source": "floating_ball", "trigger": "hover_text_input", @@ -8361,7 +8361,7 @@ func TestServiceStartTaskInfersScreenAnalyzeTitleFromScreenSummaryWhenTitlesAreM "visible_text": "fatal build error", }, }, - }) + })) if err != nil { t.Fatalf("start inferred screen task failed: %v", err) } @@ -8381,7 +8381,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 := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_screen_infer_submit", "source": "floating_ball", "trigger": "hover_text_input", @@ -8400,7 +8400,7 @@ func TestServiceSubmitInputInfersScreenAnalyzeFromVisualErrorRequest(t *testing. }, "screen_summary": "release checklist shows blocking warning", }, - }) + })) if err != nil { t.Fatalf("submit inferred screen analyze task failed: %v", err) } @@ -8417,7 +8417,7 @@ func TestServiceSubmitInputInfersScreenAnalyzeFromVisualErrorRequest(t *testing. func TestServiceStartTaskFallsBackWhenScreenCapabilityUnavailable(t *testing.T) { service := newTestService() - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_capability_fallback", "source": "floating_ball", "trigger": "hover_text_input", @@ -8433,7 +8433,7 @@ func TestServiceStartTaskFallsBackWhenScreenCapabilityUnavailable(t *testing.T) "context": map[string]any{ "screen_summary": "release validation failed on current screen", }, - }) + })) if err != nil { t.Fatalf("start task with unavailable screen capability failed: %v", err) } @@ -8456,7 +8456,7 @@ func TestServiceStartTaskFallsBackWhenScreenCapabilityUnavailable(t *testing.T) func TestServiceSubmitInputFallbackKeepsExplicitConfirmationWhenScreenCapabilityUnavailable(t *testing.T) { service := newTestService() - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_screen_capability_confirm_fallback", "source": "floating_ball", "trigger": "hover_text_input", @@ -8476,7 +8476,7 @@ func TestServiceSubmitInputFallbackKeepsExplicitConfirmationWhenScreenCapability "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("submit input with unavailable screen capability failed: %v", err) } @@ -8503,7 +8503,7 @@ 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 := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_screen_fail", "source": "floating_ball", "trigger": "hover_text_input", @@ -8517,7 +8517,7 @@ func TestSecurityRespondScreenAnalyzeFailureReconcilesTaskState(t *testing.T) { "path": "inputs/missing-screen.png", }, }, - }) + })) if err != nil { t.Fatalf("start screen analyze task failed: %v", err) } @@ -8561,7 +8561,7 @@ func TestServiceStartTaskHitsRealMemoryAndRecordsRetrievalHit(t *testing.T) { t.Fatalf("seed memory summary failed: %v", err) } - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_memory_hit", "source": "floating_ball", "trigger": "hover_text_input", @@ -8575,7 +8575,7 @@ func TestServiceStartTaskHitsRealMemoryAndRecordsRetrievalHit(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -8655,7 +8655,7 @@ func TestServiceStartTaskInjectsRetrievedMemoryIntoExecutionInput(t *testing.T) t.Fatalf("seed memory summary failed: %v", err) } - _, err := startTaskForTest(service, map[string]any{ + _, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_memory_context", "source": "floating_ball", "trigger": "hover_text_input", @@ -8669,7 +8669,7 @@ func TestServiceStartTaskInjectsRetrievedMemoryIntoExecutionInput(t *testing.T) "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -8720,7 +8720,7 @@ func TestServiceConfirmTaskPersistsRetrievalHitOncePerConfirmation(t *testing.T) t.Fatalf("seed memory summary failed: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_confirm_memory_hit", "source": "floating_ball", "trigger": "text_selected_click", @@ -8728,7 +8728,7 @@ func TestServiceConfirmTaskPersistsRetrievalHitOncePerConfirmation(t *testing.T) "type": "text_selection", "text": "请解释 project alpha 的输出格式", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -8806,7 +8806,7 @@ func TestServiceMirrorOverviewFallsBackToStoredFinishedTasks(t *testing.T) { func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { service := newTestService() - _, err := startTaskForTest(service, map[string]any{ + _, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -8820,12 +8820,12 @@ func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start completed task failed: %v", err) } - waitingResult, err := startTaskForTest(service, map[string]any{ + waitingResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -8839,7 +8839,7 @@ func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start intercepted task failed: %v", err) } @@ -8880,7 +8880,7 @@ func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { func TestServiceSecuritySummaryIncludesRuntimeTokenUsage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "executor-backed token summary") - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -8894,7 +8894,7 @@ func TestServiceSecuritySummaryIncludesRuntimeTokenUsage(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -8937,7 +8937,7 @@ func TestServiceBudgetAutoDowngradeSwitchesWorkspaceDeliveryToBubble(t *testing. }) defer unsubscribe() - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_budget_downgrade", "source": "floating_ball", "trigger": "hover_text_input", @@ -8949,7 +8949,7 @@ func TestServiceBudgetAutoDowngradeSwitchesWorkspaceDeliveryToBubble(t *testing. "confirm_required": false, "preferred_delivery": "workspace_document", }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -9104,7 +9104,7 @@ func TestServiceBudgetAutoDowngradeDisabledKeepsWorkspaceDelivery(t *testing.T) t.Fatalf("settings update failed: %v", err) } - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_budget_disabled", "source": "floating_ball", "trigger": "hover_text_input", @@ -9116,7 +9116,7 @@ func TestServiceBudgetAutoDowngradeDisabledKeepsWorkspaceDelivery(t *testing.T) "confirm_required": false, "preferred_delivery": "workspace_document", }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -9249,7 +9249,7 @@ func TestExecutionFailureBubbleRedactsSecretBearingProviderMessages(t *testing.T func TestServiceBudgetFallbackSuccessStillAppendsFailureSignal(t *testing.T) { service, _ := newTestServiceWithExecution(t, "executor-backed fallback success signal") - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": "sess_budget_fallback_signal", "source": "floating_ball", "trigger": "hover_text_input", @@ -9260,7 +9260,7 @@ func TestServiceBudgetFallbackSuccessStillAppendsFailureSignal(t *testing.T) { "options": map[string]any{ "confirm_required": false, }, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -9471,7 +9471,7 @@ func TestServiceDashboardModuleCountsRuntimeAndStoredPendingAuthorizations(t *te t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, map[string]any{ + runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_dashboard_module_mixed", "source": "floating_ball", "trigger": "hover_text_input", @@ -9485,7 +9485,7 @@ func TestServiceDashboardModuleCountsRuntimeAndStoredPendingAuthorizations(t *te "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start runtime waiting task failed: %v", err) } @@ -9542,7 +9542,7 @@ func TestServiceSecuritySummaryCountsRuntimeAndStoredPendingAuthorizations(t *te t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, map[string]any{ + runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_security_summary_mixed", "source": "floating_ball", "trigger": "hover_text_input", @@ -9556,7 +9556,7 @@ func TestServiceSecuritySummaryCountsRuntimeAndStoredPendingAuthorizations(t *te "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start runtime waiting task failed: %v", err) } @@ -9606,7 +9606,7 @@ func TestServiceSecurityPendingListMergesRuntimeAndStoredPendingAuthorizations(t t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, map[string]any{ + runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_security_pending_list_mixed", "source": "floating_ball", "trigger": "hover_text_input", @@ -9620,7 +9620,7 @@ func TestServiceSecurityPendingListMergesRuntimeAndStoredPendingAuthorizations(t "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start runtime waiting task failed: %v", err) } @@ -9687,7 +9687,7 @@ func TestServiceSecurityPendingListPaginatesMergedPendingAuthorizations(t *testi t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, map[string]any{ + runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_security_pending_list_page", "source": "floating_ball", "trigger": "hover_text_input", @@ -9701,7 +9701,7 @@ func TestServiceSecurityPendingListPaginatesMergedPendingAuthorizations(t *testi "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start runtime waiting task failed: %v", err) } @@ -9912,7 +9912,7 @@ func TestServiceSecurityRestoreApplyReturnsStorageErrorWhenApprovalPersistenceFa t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_restore_store_failure", "source": "floating_ball", "trigger": "hover_text_input", @@ -9926,7 +9926,7 @@ func TestServiceSecurityRestoreApplyReturnsStorageErrorWhenApprovalPersistenceFa "target_path": originalPath, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -9953,7 +9953,7 @@ func TestServiceSecurityRestoreApplyReturnsStorageErrorWhenApprovalPersistenceFa func TestServiceDashboardModuleHighlightsIncludeAuditTrail(t *testing.T) { service, _ := newTestServiceWithExecution(t, "dashboard audit trail") - _, err := startTaskForTest(service, map[string]any{ + _, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -9967,7 +9967,7 @@ func TestServiceDashboardModuleHighlightsIncludeAuditTrail(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -10254,7 +10254,7 @@ func TestServiceTaskDetailGetFallsBackToStoredRecoveryPointForTask(t *testing.T) t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "text_selected_click", @@ -10262,7 +10262,7 @@ func TestServiceTaskDetailGetFallsBackToStoredRecoveryPointForTask(t *testing.T) "type": "text_selection", "text": "task detail restore point fallback", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -10278,7 +10278,7 @@ func TestServiceTaskDetailGetFallsBackToStoredRecoveryPointForTask(t *testing.T) t.Fatalf("write recovery point failed: %v", err) } - result, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10295,7 +10295,7 @@ func TestServiceTaskDetailGetDropsMismatchedSummaryRecoveryPointBeforeStorageFal t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec_mismatch", "source": "floating_ball", "trigger": "text_selected_click", @@ -10303,7 +10303,7 @@ func TestServiceTaskDetailGetDropsMismatchedSummaryRecoveryPointBeforeStorageFal "type": "text_selection", "text": "task detail restore point mismatch fallback", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -10327,7 +10327,7 @@ func TestServiceTaskDetailGetDropsMismatchedSummaryRecoveryPointBeforeStorageFal t.Fatalf("write recovery point failed: %v", err) } - result, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10348,7 +10348,7 @@ func TestServiceSecurityRestoreApplyRestoresWorkspaceAndReturnsFormalResult(t *t t.Fatalf("seed original file: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -10362,7 +10362,7 @@ func TestServiceSecurityRestoreApplyRestoresWorkspaceAndReturnsFormalResult(t *t "target_path": "notes/output.md", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -10468,7 +10468,7 @@ func TestServiceSecurityRestoreApplyReturnsStructuredFailure(t *testing.T) { t.Fatalf("seed original file: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -10482,7 +10482,7 @@ func TestServiceSecurityRestoreApplyReturnsStructuredFailure(t *testing.T) { "target_path": "notes/output.md", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -10560,7 +10560,7 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) t.Fatalf("seed original file: %v", err) } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -10574,7 +10574,7 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) "target_path": "notes/output.md", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -10586,7 +10586,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 := taskDetailGetForTest(service, map[string]any{"task_id": taskID}); err != nil { + if _, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": 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}) @@ -10618,7 +10618,7 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) func TestServiceTaskDetailGetPreservesStableContractShape(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail delivery") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_detail", "source": "floating_ball", "trigger": "hover_text_input", @@ -10626,13 +10626,13 @@ func TestServiceTaskDetailGetPreservesStableContractShape(t *testing.T) { "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 := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10666,7 +10666,7 @@ func TestServiceTaskDetailGetPreservesStableContractShape(t *testing.T) { func TestServiceTaskDetailGetNormalizesProtocolCollections(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail protocol collections") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_detail_protocol", "source": "floating_ball", "trigger": "hover_text_input", @@ -10674,7 +10674,7 @@ func TestServiceTaskDetailGetNormalizesProtocolCollections(t *testing.T) { "type": "text", "text": "collect normalized detail payload", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -10706,7 +10706,7 @@ func TestServiceTaskDetailGetNormalizesProtocolCollections(t *testing.T) { t.Fatal("expected mirror reference update to succeed") } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10796,7 +10796,7 @@ func TestServiceTaskDetailGetFallsBackToStoredTaskRun(t *testing.T) { t.Fatalf("save artifact failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": "task_stored_detail"}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": "task_stored_detail"})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10871,7 +10871,7 @@ func TestServiceTaskDetailGetFallsBackToTaskRunCitationsForScreenTask(t *testing t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": "task_stored_screen_detail"}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": "task_stored_screen_detail"})) if err != nil { t.Fatalf("task detail get with stored citations failed: %v", err) } @@ -10920,7 +10920,7 @@ func TestServiceTaskDetailGetPrefersStructuredTaskStoreFallback(t *testing.T) { t.Fatalf("replace structured task steps failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": "task_structured_detail"}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": "task_structured_detail"})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10984,7 +10984,7 @@ func TestServiceTaskDetailGetUsesStructuredSnapshotWithoutReloadingTaskRuns(t *t t.Fatalf("save task run failed: %v", err) } - result, err := taskDetailGetForTest(service, map[string]any{"task_id": "task_structured_snapshot"}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": "task_structured_snapshot"})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11065,7 +11065,7 @@ func TestServiceTaskDetailGetReloadsTaskRunWhenStructuredSnapshotIsInvalid(t *te t.Fatalf("overwrite structured task failed: %v", err) } - result, err := taskDetailGetForTest(service, map[string]any{"task_id": "task_structured_invalid_snapshot"}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": "task_structured_invalid_snapshot"})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11172,7 +11172,7 @@ func TestServiceTaskDetailGetStructuredFallbackUsesSessionAndRunStores(t *testin }); err != nil { t.Fatalf("write run failed: %v", err) } - result, err := taskDetailGetForTest(service, map[string]any{"task_id": "task_structured_session_run"}) + result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": "task_structured_session_run"})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11222,7 +11222,7 @@ func TestServiceTaskDetailGetPrefersRuntimeStateWhenStructuredRowIsStale(t *test }); err != nil { t.Fatalf("write stale structured task failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": runtimeTask.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": runtimeTask.TaskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11333,7 +11333,7 @@ func TestServiceTaskDetailGetStructuredFallbackBackfillsTaskRunEvidence(t *testi t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11409,7 +11409,7 @@ func TestServiceTaskDetailGetStructuredFallbackReadsFormalDeliveryFromLoopStore( t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11489,7 +11489,7 @@ func TestServiceTaskDetailGetStructuredFallbackReadsFormalCitationsFromLoopStore t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11610,7 +11610,7 @@ func TestServiceTaskDetailGetStructuredFallbackPrefersFirstClassDeliveryAndCitat t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11691,7 +11691,7 @@ func TestServiceTaskDetailGetStructuredFallbackNormalizesSparseDeliveryPayloadKe t.Fatalf("delete sparse runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11823,7 +11823,7 @@ func TestServiceRestartedStructuredTaskFallsBackToCurrentSnapshotFormalData(t *t t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11921,7 +11921,7 @@ func TestServiceTaskDetailGetStructuredFallbackRehydratesApprovalRequest(t *test t.Fatalf("write approval request failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12008,7 +12008,7 @@ func TestServiceTaskDetailGetStructuredFallbackRehydratesAuthorizationAndAudit(t t.Fatalf("write structured audit record failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12162,7 +12162,7 @@ func TestServiceTaskDetailGetPrefersStoredScreenFormalObjectsOverRuntimeCompatib t.Fatalf("write stored audit record failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": runtimeTask.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": runtimeTask.TaskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12233,7 +12233,7 @@ func TestServiceTaskDetailGetPrefersStoredApprovalRequestOverRuntimeCompatibilit t.Fatalf("write stored approval request failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": runtimeTask.TaskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": runtimeTask.TaskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12387,7 +12387,7 @@ func TestServiceTaskDetailGetStructuredScreenFallbackPrefersFormalEvidenceObject t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12590,7 +12590,7 @@ func TestServiceTaskDetailGetReloadsTaskRunWhenFormalScreenObjectsMaskInvalidSna t.Fatalf("rewrite structured task with invalid snapshot failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12681,7 +12681,7 @@ func TestServiceTaskDetailGetScreenAuditPrefersNewerTerminalGovernanceRecord(t * t.Fatalf("write recovery audit record failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12760,7 +12760,7 @@ func TestServiceTaskDetailGetStructuredScreenApprovalPrefersFormalApprovalReques t.Fatalf("write formal screen approval request failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13063,7 +13063,7 @@ func TestServiceTaskArtifactOpenPrefersStoredArtifactOverRuntimeCompatibility(t func TestServiceTaskArtifactOpenReturnsArtifactNotFoundWhenTaskExists(t *testing.T) { service, _ := newTestServiceWithExecution(t, "artifact not found") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_artifact_not_found", "source": "floating_ball", "trigger": "hover_text_input", @@ -13077,7 +13077,7 @@ func TestServiceTaskArtifactOpenReturnsArtifactNotFoundWhenTaskExists(t *testing "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -13093,7 +13093,7 @@ func TestServiceStartTaskPersistsArtifactsToStore(t *testing.T) { if service.storage == nil { t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_artifact_persist", "source": "floating_ball", "trigger": "hover_text_input", @@ -13107,7 +13107,7 @@ func TestServiceStartTaskPersistsArtifactsToStore(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -13131,7 +13131,7 @@ func TestServiceStartTaskPersistsArtifactsToStore(t *testing.T) { func TestServiceDeliveryOpenReturnsTaskDeliveryResult(t *testing.T) { service, _ := newTestServiceWithExecution(t, "delivery open task") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_delivery_open", "source": "floating_ball", "trigger": "hover_text_input", @@ -13145,7 +13145,7 @@ func TestServiceDeliveryOpenReturnsTaskDeliveryResult(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -13264,7 +13264,7 @@ func TestServiceResultPageFallbackWithoutFormalDeliveryRowKeepsOpenAndDetailStab t.Fatalf("write structured task failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13343,7 +13343,7 @@ func TestServiceLegacyResultPageSnapshotOverridesBubbleCompatShape(t *testing.T) t.Fatalf("write structured task failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13411,7 +13411,7 @@ func TestServiceResultPagePayloadNormalizationOverridesLegacyBadValues(t *testin t.Fatalf("save delivery result failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13536,7 +13536,7 @@ func TestTaskDeliveryHelpersSupportResultPage(t *testing.T) { func TestServiceTaskArtifactListFallsBackToRuntimeArtifactsWhenStoreEmpty(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_runtime_artifact", "source": "floating_ball", "trigger": "hover_text_input", @@ -13550,7 +13550,7 @@ func TestServiceTaskArtifactListFallsBackToRuntimeArtifactsWhenStoreEmpty(t *tes "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -13587,7 +13587,7 @@ func TestServiceTaskArtifactListMergesStoredAndRuntimeArtifacts(t *testing.T) { if service.storage == nil || service.storage.ArtifactStore() == nil { t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_merge_artifact", "source": "floating_ball", "trigger": "hover_text_input", @@ -13598,7 +13598,7 @@ func TestServiceTaskArtifactListMergesStoredAndRuntimeArtifacts(t *testing.T) { "intent": map[string]any{ "name": "summarize", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -13673,7 +13673,7 @@ func TestServiceTaskArtifactListMergesStoredAndRuntimeArtifacts(t *testing.T) { func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_runtime_missing_artifact_id", "source": "floating_ball", "trigger": "hover_text_input", @@ -13681,7 +13681,7 @@ func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t * "type": "text", "text": "只检查运行态 artifact id", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -13701,7 +13701,7 @@ func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t * "delivery_payload": map[string]any{"path": "workspace/runtime-output.txt", "task_id": taskID}, }}) - detailResult, err := taskDetailGetForTest(service, map[string]any{"task_id": taskID}) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13735,7 +13735,7 @@ func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t * func TestServiceTaskControlRejectsInvalidStatusTransition(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "text_selected_click", @@ -13743,7 +13743,7 @@ func TestServiceTaskControlRejectsInvalidStatusTransition(t *testing.T) { "type": "text_selection", "text": "this task still requires confirmation", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14497,7 +14497,7 @@ func TestDashboardModuleGetIncludesPluginRuntimeSummary(t *testing.T) { func TestDashboardModuleGetTasksIncludesFocusRuntimeSummary(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_dashboard_tasks_runtime", "source": "floating_ball", "trigger": "hover_text_input", @@ -14511,7 +14511,7 @@ func TestDashboardModuleGetTasksIncludesFocusRuntimeSummary(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14570,7 +14570,7 @@ func TestServiceTaskControlRequiresTaskID(t *testing.T) { func TestServiceTaskControlRequiresAction(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -14584,7 +14584,7 @@ func TestServiceTaskControlRequiresAction(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14601,7 +14601,7 @@ func TestServiceTaskControlRequiresAction(t *testing.T) { func TestServiceTaskControlRejectsFinishedTaskOperations(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_demo", "source": "floating_ball", "trigger": "hover_text_input", @@ -14615,7 +14615,7 @@ func TestServiceTaskControlRejectsFinishedTaskOperations(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14633,7 +14633,7 @@ func TestServiceTaskControlRejectsFinishedTaskOperations(t *testing.T) { func TestServiceTaskControlReturnsUpdatedTaskAndBubbleForWaitingAuthCancel(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_task_control_payload", "source": "floating_ball", "trigger": "hover_text_input", @@ -14647,7 +14647,7 @@ func TestServiceTaskControlReturnsUpdatedTaskAndBubbleForWaitingAuthCancel(t *te "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -14992,7 +14992,7 @@ func TestPersistExecutionToolCallEventsFallsBackWhenToolCallIDMissing(t *testing func TestServiceTaskSteerPersistsFollowUpMessage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task steer") - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_task_steer", "source": "floating_ball", "trigger": "hover_text_input", @@ -15006,7 +15006,7 @@ func TestServiceTaskSteerPersistsFollowUpMessage(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -15228,7 +15228,7 @@ func TestServiceSubmitInputRoutesFollowUpIntoExistingTask(t *testing.T) { activeTaskID = activeTask.TaskID activeSessionID := activeTask.SessionID - followUpResult, err := submitInputForTest(service, map[string]any{ + followUpResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -15237,7 +15237,7 @@ func TestServiceSubmitInputRoutesFollowUpIntoExistingTask(t *testing.T) { "input_mode": "text", }, "context": map[string]any{}, - }) + })) if err != nil { t.Fatalf("submit follow-up failed: %v", err) } @@ -15269,7 +15269,7 @@ func TestServiceSubmitInputQueuesNewTaskWhenActivePromptTaskCannotConsumeSteerin RiskLevel: "green", }) - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "hover_text_input", @@ -15279,7 +15279,7 @@ func TestServiceSubmitInputQueuesNewTaskWhenActivePromptTaskCannotConsumeSteerin "input_mode": "text", }, "context": map[string]any{}, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -15302,7 +15302,7 @@ func TestServiceSubmitInputQueuesNewTaskWhenActivePromptTaskCannotConsumeSteerin func TestServiceStartTaskNotificationIncludesSessionID(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_notification_contract", "source": "floating_ball", "trigger": "hover_text_input", @@ -15310,7 +15310,7 @@ func TestServiceStartTaskNotificationIncludesSessionID(t *testing.T) { "type": "text", "text": "Summarize this update", }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -15366,7 +15366,7 @@ func TestServiceStartTaskDescribedFileDoesNotAttachToProcessingTask(t *testing.T }) activeTaskID = activeTask.TaskID - followUpResult, err := startTaskForTest(service, map[string]any{ + followUpResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "file_drop", "input": map[string]any{ @@ -15377,7 +15377,7 @@ func TestServiceStartTaskDescribedFileDoesNotAttachToProcessingTask(t *testing.T "options": map[string]any{ "confirm_required": false, }, - }) + })) if err != nil { t.Fatalf("start file follow-up failed: %v", err) } @@ -15415,7 +15415,7 @@ func TestServiceStartTaskDescribedFileStartsNewTaskWithoutPendingEvidence(t *tes }, }) - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": waitingTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15427,7 +15427,7 @@ func TestServiceStartTaskDescribedFileStartsNewTaskWithoutPendingEvidence(t *tes "options": map[string]any{ "confirm_required": false, }, - }) + })) if err != nil { t.Fatalf("start described file task failed: %v", err) } @@ -15466,7 +15466,7 @@ func TestServiceStartTaskShellBallAnchorDoesNotContinuePendingFileTask(t *testin }, }) - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": waitingTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15484,7 +15484,7 @@ func TestServiceStartTaskShellBallAnchorDoesNotContinuePendingFileTask(t *testin "options": map[string]any{ "confirm_required": false, }, - }) + })) if err != nil { t.Fatalf("start shell-ball described file task failed: %v", err) } @@ -15563,7 +15563,7 @@ func TestServiceStartTaskConfirmRequiredFileDoesNotContinueProcessingTask(t *tes }) activeTaskID = activeTask.TaskID - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -15575,7 +15575,7 @@ func TestServiceStartTaskConfirmRequiredFileDoesNotContinueProcessingTask(t *tes "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("start confirm-required file task failed: %v", err) } @@ -15623,7 +15623,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesPendingTask(t *testing.T) RiskLevel: "green", }) - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "hover_text_input", @@ -15635,7 +15635,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesPendingTask(t *testing.T) "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("submit confirm-required text follow-up failed: %v", err) } @@ -15683,7 +15683,7 @@ func TestServiceSubmitInputFallsBackWhenContinuationModelTimesOut(t *testing.T) }) start := time.Now() - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -15695,7 +15695,7 @@ func TestServiceSubmitInputFallsBackWhenContinuationModelTimesOut(t *testing.T) "confirm_required": true, }, "context": map[string]any{}, - }) + })) if err != nil { t.Fatalf("submit input failed: %v", err) } @@ -15744,7 +15744,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesImplicitPendingTask(t *te }, }) - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -15755,7 +15755,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesImplicitPendingTask(t *te "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("submit implicit confirm-required text follow-up failed: %v", err) } @@ -15822,7 +15822,7 @@ func TestServiceSubmitInputPlainTextKeepsConfirmingTaskBehindConfirmation(t *tes }, }) - result, err := submitInputForTest(service, map[string]any{ + result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "hover_text_input", @@ -15831,7 +15831,7 @@ func TestServiceSubmitInputPlainTextKeepsConfirmingTaskBehindConfirmation(t *tes "text": "Use the latest customer impact numbers.", "input_mode": "text", }, - }) + })) if err != nil { t.Fatalf("submit plain text follow-up for confirming task failed: %v", err) } @@ -15906,14 +15906,14 @@ func TestServiceStartTaskPlainTextImplicitPendingTaskStartsNewWithoutExplicitCon }) activeTaskID = activeTask.TaskID - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ "type": "text", "text": "Translate this email.", }, - }) + })) if err != nil { t.Fatalf("start implicit plain text new task failed: %v", err) } @@ -16037,7 +16037,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesWaitingInputTask(t *testing }) activeTaskID = activeTask.TaskID - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -16053,7 +16053,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesWaitingInputTask(t *testing "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("continue waiting-input file task failed: %v", err) } @@ -16114,7 +16114,7 @@ func TestServiceStartTaskStructuredSupplementContinuesPendingTaskWithoutAutoExec }, }) - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -16131,7 +16131,7 @@ func TestServiceStartTaskStructuredSupplementContinuesPendingTaskWithoutAutoExec "options": map[string]any{ "confirm_required": false, }, - }) + })) if err != nil { t.Fatalf("continue structured supplement failed: %v", err) } @@ -16176,7 +16176,7 @@ func TestServiceStartTaskStructuredSupplementResumesWaitingTaskWithConfirmedInte }, }) - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -16192,7 +16192,7 @@ func TestServiceStartTaskStructuredSupplementResumesWaitingTaskWithConfirmedInte "options": map[string]any{ "confirm_required": false, }, - }) + })) if err != nil { t.Fatalf("resume structured supplement failed: %v", err) } @@ -16262,7 +16262,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesUniquePendingTaskAmongCandi }, }) - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": targetTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -16279,7 +16279,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesUniquePendingTaskAmongCandi "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("continue unique multi-candidate file task failed: %v", err) } @@ -16337,7 +16337,7 @@ func TestServiceStartTaskConfirmRequiredFileStartsNewTaskWithoutPendingEvidence( }) activeTaskID = activeTask.TaskID - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": activeTask.SessionID, "source": "floating_ball", "trigger": "file_drop", @@ -16353,7 +16353,7 @@ func TestServiceStartTaskConfirmRequiredFileStartsNewTaskWithoutPendingEvidence( "options": map[string]any{ "confirm_required": true, }, - }) + })) if err != nil { t.Fatalf("start unanchored confirm-required file task failed: %v", err) } @@ -16379,7 +16379,7 @@ func TestServiceStartTaskConfirmRequiredFileStartsNewTaskWithoutPendingEvidence( func TestServiceSubmitInputDoesNotContinueWaitingAuthorizationTask(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, map[string]any{ + startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16392,13 +16392,13 @@ func TestServiceSubmitInputDoesNotContinueWaitingAuthorizationTask(t *testing.T) "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start waiting_auth task failed: %v", err) } firstTask := startResult["task"].(map[string]any) - followUpResult, err := submitInputForTest(service, map[string]any{ + followUpResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16407,7 +16407,7 @@ func TestServiceSubmitInputDoesNotContinueWaitingAuthorizationTask(t *testing.T) "input_mode": "text", }, "context": map[string]any{}, - }) + })) if err != nil { t.Fatalf("submit follow-up after waiting_auth failed: %v", err) } @@ -16435,7 +16435,7 @@ func TestServiceSubmitInputDoesNotContinuePausedTask(t *testing.T) { t.Fatalf("pause task failed: %v", err) } - followUpResult, err := submitInputForTest(service, map[string]any{ + followUpResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16444,7 +16444,7 @@ func TestServiceSubmitInputDoesNotContinuePausedTask(t *testing.T) { "input_mode": "text", }, "context": map[string]any{}, - }) + })) if err != nil { t.Fatalf("submit follow-up after pause failed: %v", err) } @@ -16466,7 +16466,7 @@ func TestServiceStartTaskWithExplicitIntentDoesNotReuseWaitingTaskWithoutAnchors RiskLevel: "green", }) - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16479,7 +16479,7 @@ func TestServiceStartTaskWithExplicitIntentDoesNotReuseWaitingTaskWithoutAnchors "target_path": "workspace/reports/weekly.md", }, }, - }) + })) if err != nil { t.Fatalf("start explicit new task failed: %v", err) } @@ -16509,7 +16509,7 @@ func TestServiceSubmitInputStartsNewTaskForUnrelatedRequest(t *testing.T) { }, }) - firstResult, err := startTaskForTest(service, map[string]any{ + firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16522,13 +16522,13 @@ func TestServiceSubmitInputStartsNewTaskForUnrelatedRequest(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("first task failed: %v", err) } firstTask := firstResult["task"].(map[string]any) - secondResult, err := submitInputForTest(service, map[string]any{ + secondResult, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16537,7 +16537,7 @@ func TestServiceSubmitInputStartsNewTaskForUnrelatedRequest(t *testing.T) { "input_mode": "text", }, "context": map[string]any{}, - }) + })) if err != nil { t.Fatalf("second task failed: %v", err) } @@ -16560,7 +16560,7 @@ func TestServiceReusesRecentIdleSessionForNewTopLevelTask(t *testing.T) { RiskLevel: "green", }) - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "source": "floating_ball", "trigger": "hover_text_input", "input": map[string]any{ @@ -16573,7 +16573,7 @@ func TestServiceReusesRecentIdleSessionForNewTopLevelTask(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task after idle session failed: %v", err) } @@ -16586,7 +16586,7 @@ func TestServiceReusesRecentIdleSessionForNewTopLevelTask(t *testing.T) { func TestServiceTaskListIncludesLoopStopReason(t *testing.T) { service := newTestService() for index := 0; index < 2; index++ { - _, err := startTaskForTest(service, map[string]any{ + _, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": fmt.Sprintf("sess_loop_stop_%02d", index), "source": "floating_ball", "trigger": "hover_text_input", @@ -16600,7 +16600,7 @@ func TestServiceTaskListIncludesLoopStopReason(t *testing.T) { "require_authorization": true, }, }, - }) + })) if err != nil { t.Fatalf("start task %d failed: %v", index, err) } @@ -16908,7 +16908,7 @@ func TestServiceTaskListDoesNotDoubleCountRuntimeTasksBackedByLegacyRows(t *test func TestServiceStartTaskWithExecutorWritesWorkspaceDocument(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "第一点\n第二点\n第三点") - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -16922,7 +16922,7 @@ func TestServiceStartTaskWithExecutorWritesWorkspaceDocument(t *testing.T) { "style": "key_points", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -16970,7 +16970,7 @@ func TestServiceStartTaskWithExecutorWritesWorkspaceDocument(t *testing.T) { func TestServiceStartTaskWithExecutorReturnsGeneratedBubble(t *testing.T) { service, _ := newTestServiceWithExecution(t, "这段内容主要在解释当前问题的原因和处理方向。") - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_exec", "source": "floating_ball", "trigger": "hover_text_input", @@ -16982,7 +16982,7 @@ func TestServiceStartTaskWithExecutorReturnsGeneratedBubble(t *testing.T) { "name": "explain", "arguments": map[string]any{}, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -17021,7 +17021,7 @@ func TestServiceStartTaskWithExecutorDeliversPageReadResultPage(t *testing.T) { Source: "playwright_sidecar", }}) - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_page_read", "source": "floating_ball", "trigger": "hover_text_input", @@ -17035,7 +17035,7 @@ func TestServiceStartTaskWithExecutorDeliversPageReadResultPage(t *testing.T) { "url": "https://example.com", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -17101,7 +17101,7 @@ func TestServiceStartTaskWithExecutorDeliversPageSearchResultPage(t *testing.T) Source: "playwright_sidecar", }}) - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_page_search", "source": "floating_ball", "trigger": "hover_text_input", @@ -17117,7 +17117,7 @@ func TestServiceStartTaskWithExecutorDeliversPageSearchResultPage(t *testing.T) "limit": 2, }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -17156,7 +17156,7 @@ 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 := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_ocr_extract", "source": "floating_ball", "trigger": "hover_text_input", @@ -17170,7 +17170,7 @@ func TestServiceWorkerToolWritesToolCallEventNotification(t *testing.T) { "path": "notes/demo.txt", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -17205,7 +17205,7 @@ 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 := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_media_transcode", "source": "floating_ball", "trigger": "hover_text_input", @@ -17221,7 +17221,7 @@ func TestServiceMediaWorkerPropagatesArtifactsAndWorkerEventPayload(t *testing.T "format": "mp4", }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -17260,7 +17260,7 @@ func TestServiceMediaWorkerPropagatesArtifactsAndWorkerEventPayload(t *testing.T func TestServiceStartTaskWithExecutorPageReadFailureUsesUnifiedError(t *testing.T) { service, _ := newTestServiceWithExecutionAndPlaywright(t, "unused", platform.LocalExecutionBackend{}, nil, stubPlaywrightClient{err: tools.ErrPlaywrightSidecarFailed}) - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_page_read_fail", "source": "floating_ball", "trigger": "hover_text_input", @@ -17274,7 +17274,7 @@ func TestServiceStartTaskWithExecutorPageReadFailureUsesUnifiedError(t *testing. "url": "https://example.com", }, }, - }) + })) if err != nil { t.Fatalf("start task should return task-centric failure result, got %v", err) } @@ -17336,7 +17336,7 @@ func TestServiceStartTaskWithRealLocalPageReadDelivery(t *testing.T) { }() service, _ := newTestServiceWithExecutionAndPlaywright(t, "unused", platform.LocalExecutionBackend{}, nil, localHTTPPlaywrightClient{}) - result, err := startTaskForTest(service, map[string]any{ + result, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ "session_id": "sess_real_page_read", "source": "floating_ball", "trigger": "hover_text_input", @@ -17350,7 +17350,7 @@ func TestServiceStartTaskWithRealLocalPageReadDelivery(t *testing.T) { "url": "http://" + listener.Addr().String(), }, }, - }) + })) if err != nil { t.Fatalf("start task failed: %v", err) } From 974823b2927d199f5a96435ec519f521bfa399dd Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 10 May 2026 17:34:07 +0000 Subject: [PATCH 29/41] test(orchestrator): use typed task entry requests Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/orchestrator/service_test.go | 2645 ++++------------- 1 file changed, 554 insertions(+), 2091 deletions(-) diff --git a/services/local-service/internal/orchestrator/service_test.go b/services/local-service/internal/orchestrator/service_test.go index 0d0e56b4c..0823a6db6 100644 --- a/services/local-service/internal/orchestrator/service_test.go +++ b/services/local-service/internal/orchestrator/service_test.go @@ -1127,15 +1127,7 @@ func TestServiceStartTaskAndConfirmFlow(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -1190,15 +1182,7 @@ func TestServiceSubmitInputReturnsSocialChatWithoutTask(t *testing.T) { testCases := []string{"你好", "🙂"} for index, testCase := range testCases { t.Run(testCase, func(t *testing.T) { - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1228,15 +1212,7 @@ func TestServiceSubmitInputRoutesUnanchoredAmbiguousTextToConfirmation(t *testin output: `{"route":"clarification_needed","reply":""}`, }) - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1270,15 +1246,7 @@ func TestServiceSubmitInputKeepsTaskRequestAfterClassifier(t *testing.T) { }, }) - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1311,15 +1279,7 @@ func TestServiceSubmitInputFallsBackToTaskWhenClassifierFails(t *testing.T) { }, }) - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1336,18 +1296,7 @@ func TestServiceSubmitInputFallsBackToTaskWhenClassifierFails(t *testing.T) { func TestServiceSubmitInputRespectsExplicitConfirmationForFreeText(t *testing.T) { service := newTestService() - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1368,15 +1317,7 @@ func TestServiceSubmitInputRespectsExplicitConfirmationForFreeText(t *testing.T) func TestServiceSubmitInputRoutesClearCommandToAgentLoopWithoutForcedConfirmation(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Translated note ready.") - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1401,15 +1342,7 @@ func TestServiceSubmitInputRoutesClearCommandToAgentLoopWithoutForcedConfirmatio func TestServiceSubmitInputUsesSuggestedWorkspaceDeliveryForLongAgentLoopInput(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "Long-form result body.") - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1438,19 +1371,10 @@ func TestServiceStartTaskFailsAfterExecutionTimeout(t *testing.T) { }) service.executionTimeout = 20 * time.Millisecond - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -1510,15 +1434,7 @@ func TestServiceSubmitInputWorkspaceDeliverySkipsShortBubbleTimeout(t *testing.T }) service.executionTimeout = 20 * time.Millisecond - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1544,22 +1460,13 @@ func TestServiceSubmitInputWorkspaceDeliverySkipsShortBubbleTimeout(t *testing.T func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued task output.") - firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -1567,15 +1474,7 @@ func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *tes t.Fatalf("expected first task to wait for authorization, got %+v", firstResult["task"]) } - secondResult, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1595,22 +1494,13 @@ func TestServiceSubmitInputQueuesDirectAgentLoopTaskBehindSameSessionWork(t *tes func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued confirm output.") - firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -1618,18 +1508,7 @@ func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T t.Fatalf("expected first task to wait for authorization, got %+v", firstResult["task"]) } - secondResult, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1658,50 +1537,25 @@ func TestServiceConfirmTaskQueuesCorrectedTaskBehindSameSessionWork(t *testing.T func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued cancel output.") - firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := submitInputForTest(service, SubmitInputRequestFromParams(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 := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1731,51 +1585,22 @@ func TestServiceTaskControlCancelQueuedTaskDoesNotResumeWhileSessionBusy(t *test func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Snapshot resume output.") - firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -1811,36 +1636,19 @@ func TestServiceSecurityRespondResumesQueuedTaskWithOriginalSnapshot(t *testing. func TestServiceSecurityRespondResumesQueuedSessionTask(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Queued task resumed output.") - firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -1877,42 +1685,24 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * t.Fatalf("write screen input failed: %v", err) } - firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -1953,7 +1743,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 := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -1984,18 +1774,7 @@ func TestServiceSecurityRespondResumesQueuedScreenAnalyzeTaskThroughApproval(t * func TestServiceConfirmTaskRunsStoredAgentLoopIntentWithoutCorrection(t *testing.T) { service, _ := newTestServiceWithModelClient(t, &stubToolCallingModelClient{}) - startResult, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -2101,18 +1880,7 @@ func TestExecuteTaskResultPageWaitingInputDoesNotClaimDeliveryInTrace(t *testing func TestServiceConfirmTaskKeepsUnknownIntentInConfirmationWhenRejected(t *testing.T) { service := newTestService() - startResult, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -2148,18 +1916,7 @@ func TestServiceConfirmTaskKeepsUnknownIntentInConfirmationWhenRejected(t *testi func TestServiceConfirmTaskRewritesPlaceholderTitleAfterCorrection(t *testing.T) { service := newTestService() - startResult, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -2186,15 +1943,7 @@ func TestServiceConfirmTaskRewritesPlaceholderTitleAfterCorrection(t *testing.T) func TestServiceConfirmTaskIgnoresCorrectedIntentWhenConfirmedTrue(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Explained content.") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -2230,15 +1979,7 @@ func TestServiceConfirmTaskIgnoresCorrectedIntentWhenConfirmedTrue(t *testing.T) func TestServiceConfirmTaskRejectsOutOfPhaseRequest(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -2902,15 +2643,7 @@ func TestServiceNotepadUpdateReturnsDeletedItemID(t *testing.T) { func TestServiceExecutionAuditIDsStayUniqueAcrossToolAndTaskRecords(t *testing.T) { service, _ := newTestServiceWithExecution(t, "runtime output") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -2944,15 +2677,7 @@ func TestServiceExecutionAuditIDsStayUniqueAcrossToolAndTaskRecords(t *testing.T func TestServiceRecommendationGetUsesRuntimeTaskState(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -3506,19 +3231,10 @@ func TestServiceTaskControlRestartExecutesFreshAttempt(t *testing.T) { modelClient := &stubToolCallingModelClient{output: "Restarted loop output."} service, _ := newTestServiceWithModelClient(t, modelClient) - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -3883,7 +3599,7 @@ func TestServiceTaskDetailGetRestartAttemptHidesPreviousRunFormalObjects(t *test t.Fatalf("expected restart to allocate a fresh processing attempt, got %+v", restartedTask) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -3959,7 +3675,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 := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -4163,18 +3879,7 @@ func TestServiceRecommendationFeedbackSubmitAppliesCooldown(t *testing.T) { func TestServiceSubmitInputWithFilesDoesNotWaitForInput(t *testing.T) { service := newTestService() - result, err := submitInputForTest(service, SubmitInputRequestFromParams(map[string]any{ - "session_id": "sess_files", - "source": "floating_ball", - "input": map[string]any{}, - "context": map[string]any{ - "files": []any{"workspace/notes.md"}, - "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) } @@ -4206,17 +3911,7 @@ func TestServiceSubmitInputEmptyTextReturnsWaitingInput(t *testing.T) { t.Fatalf("new service: %v", err) } - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -4271,21 +3966,12 @@ func TestServiceDirectStartBuildsMemoryAndDeliveryHandoffs(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4326,25 +4012,12 @@ func TestServiceDirectStartBuildsMemoryAndDeliveryHandoffs(t *testing.T) { func TestServiceStartTaskRespectsPreferredDelivery(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4376,20 +4049,7 @@ func TestServiceStartTaskRespectsPreferredDelivery(t *testing.T) { func TestServiceStartTaskFileInstructionSkipsForcedIntentConfirmation(t *testing.T) { service, _ := newTestServiceWithExecution(t, "Attachment summary ready.") - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4412,18 +4072,7 @@ func TestServiceStartTaskFileInstructionSkipsForcedIntentConfirmation(t *testing func TestServiceStartTaskFileWithoutInstructionStillRequiresConfirmation(t *testing.T) { service := newTestService() - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4454,21 +4103,12 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { t.Fatalf("write source file: %v", err) } - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4524,7 +4164,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { t.Fatalf("expected persisted direct delivery result, ok=%v record=%+v", ok, deliveryRecord) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -4541,19 +4181,7 @@ func TestServiceStartTaskPersistsFormalReadFileSampleChain(t *testing.T) { func TestServiceSubmitInputRespectsPreferredDelivery(t *testing.T) { service := newTestService() - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -4587,18 +4215,7 @@ func TestServiceSubmitInputRespectsPreferredDelivery(t *testing.T) { func TestServiceConfirmTaskRespectsStoredPreferredDelivery(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4648,22 +4265,13 @@ func TestServiceStartTaskWaitingAuthDoesNotSetFinishedAt(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4704,15 +4312,7 @@ func TestServiceConfirmCanEnterWaitingAuth(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4771,15 +4371,7 @@ func TestServiceConfirmWaitingAuthPersistsApprovalRequestRecord(t *testing.T) { t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4824,15 +4416,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4872,15 +4456,7 @@ func TestServiceSecurityRespondAllowOnceResumesAndCompletes(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -4965,19 +4541,7 @@ func TestServiceSecurityRespondAllowOnceResumesAndCompletes(t *testing.T) { func TestServiceSecurityRespondRespectsFallbackDelivery(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5036,15 +4600,7 @@ func TestServiceSecurityRespondDenyOnceCancelsTask(t *testing.T) { t.Fatalf("new service: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5101,15 +4657,7 @@ func TestServiceSecurityRespondPersistsAuthorizationRecord(t *testing.T) { t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5171,15 +4719,7 @@ func TestServiceSecurityRespondReturnsStorageErrorWhenAuthorizationPersistenceFa originalStore := service.storage.AuthorizationRecordStore() defer replaceAuthorizationRecordStore(t, service.storage, originalStore) - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5225,21 +4765,12 @@ func TestServiceSecurityRespondRejectsOutOfPhaseAuthorizationPersistence(t *test t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5336,21 +4867,12 @@ func TestServiceSecurityRespondRejectsUnsupportedDecision(t *testing.T) { t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5393,21 +4915,12 @@ func TestServiceSecurityRespondKeepsAuthorizationHistoryAcrossMultipleCycles(t * t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5464,21 +4977,12 @@ func TestServiceStartTaskWriteFileOverwriteWaitsForApproval(t *testing.T) { t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5502,22 +5006,13 @@ func TestServiceStartTaskWriteFileOverwriteWaitsForApproval(t *testing.T) { func TestServiceStartTaskExecCommandWaitsForApproval(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "unused") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5541,21 +5036,12 @@ func TestServiceStartTaskExecCommandWaitsForApproval(t *testing.T) { func TestServiceStartTaskOutOfWorkspaceWriteIsBlocked(t *testing.T) { service, _ := newTestServiceWithExecution(t, "unused") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5578,22 +5064,13 @@ func TestServiceStartTaskOutOfWorkspaceWriteIsBlocked(t *testing.T) { func TestServiceSecurityRespondAllowOnceReturnsStructuredExecutionFailure(t *testing.T) { service, _ := newTestServiceWithExecutionOptions(t, "unused", failingExecutionBackend{err: errors.New("runner unavailable")}, nil) - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5640,22 +5117,13 @@ func TestServiceSecurityRespondAllowOnceExecCommandCompletesAfterApproval(t *tes t.Fatalf("mkdir workspace root: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5684,22 +5152,13 @@ func TestServiceSecurityRespondAllowOnceExecCommandCompletesAfterApproval(t *tes func TestServiceSecurityRespondAllowOnceCompletesDerivedWriteFileAfterApproval(t *testing.T) { service, _ := newTestServiceWithExecution(t, "新的文档内容") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(map[string]any{ - "session_id": "sess_derived_write", - "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", - "require_authorization": true, - }, + 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, }, - })) + }}) if err != nil { t.Fatalf("start task failed: %v", err) } @@ -5731,21 +5190,12 @@ func TestServiceSecurityRespondAllowOnceReturnsStructuredRecoveryFailure(t *test t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -5793,40 +5243,22 @@ func TestServiceTaskListSupportsSortParams(t *testing.T) { return current }) - firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -6229,21 +5661,12 @@ func TestServiceTaskListUsesStructuredStoreUnlimitedPaginationInMemoryFallback(t func TestServiceTaskListMergesStructuredStorageWhenOffsetExceedsRuntimePage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "runtime paging") - _, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -6292,21 +5715,12 @@ func TestServiceTaskListClampsPagingParams(t *testing.T) { service := newTestService() for index := 0; index < 25; index++ { - _, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -6469,40 +5883,22 @@ func TestServiceTaskListFallbackMatchesRuntimeSortTieBreaker(t *testing.T) { func TestServiceDashboardOverviewUsesRuntimeAggregation(t *testing.T) { service := newTestService() - completedResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -6652,27 +6048,18 @@ func TestIsWorkspaceRelativePathKeepsArtifactsInsideWorkspaceScope(t *testing.T) func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6700,21 +6087,12 @@ func TestServiceTaskDetailGetExposesActiveApprovalAnchor(t *testing.T) { func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -6732,7 +6110,7 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi runtimeRecord.SecuritySummary["pending_authorizations"] = 1 }) - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6749,21 +6127,12 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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", - }, - "intent": map[string]any{ - "name": "write_file", - "arguments": map[string]any{ - "require_authorization": true, - }, + 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) } @@ -6773,7 +6142,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing. runtimeRecord.ApprovalRequest["task_id"] = "task_other" }) - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6790,21 +6159,12 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWithMismatchedTaskID(t *testing. func TestServiceTaskDetailGetDropsApprovalAnchorWhenStatusIsNotPending(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -6814,7 +6174,7 @@ func TestServiceTaskDetailGetDropsApprovalAnchorWhenStatusIsNotPending(t *testin runtimeRecord.ApprovalRequest["status"] = "approved" }) - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6828,21 +6188,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -6879,7 +6230,7 @@ func TestServiceTaskDetailGetIncludesRuntimeSummary(t *testing.T) { t.Fatal("expected steering append to succeed") } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6909,15 +6260,7 @@ func TestServiceTaskDetailGetIncludesRuntimeSummary(t *testing.T) { func TestServiceTaskDetailGetKeepsRuntimeSummaryScopedToRuntimeEvents(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail runtime event scope") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -6926,7 +6269,7 @@ func TestServiceTaskDetailGetKeepsRuntimeSummaryScopedToRuntimeEvents(t *testing t.Fatal("expected steering append to succeed") } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -6984,7 +6327,7 @@ func TestServiceTaskDetailGetIncludesFailureSummaryForFailedScreenTask(t *testin }, }, tools.ErrOCRWorkerFailed) - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -7279,40 +6622,22 @@ func TestServiceDashboardOverviewRespectsIncludeFilter(t *testing.T) { func TestServiceDashboardOverviewFocusModeNarrowsSecondaryData(t *testing.T) { service := newTestService() - _, err := startTaskForTest(service, StartTaskRequestFromParams(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 = startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -7433,21 +6758,12 @@ func TestServiceDashboardOverviewResortsMergedRuntimeAndStoredTasks(t *testing.T t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -7516,21 +6832,12 @@ func TestServiceDashboardOverviewLoadsStoredTasksOncePerRequest(t *testing.T) { countingStore := &countingTaskStore{base: service.storage.TaskStore()} replaceTaskStore(t, service.storage, countingStore) - if _, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -7568,21 +6875,12 @@ func TestServiceDashboardOverviewLoadsStoredTasksOncePerRequest(t *testing.T) { func TestServiceMirrorOverviewUsesRuntimeMirrorReferences(t *testing.T) { service := newTestService() - _, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -7617,21 +6915,12 @@ func TestServiceStartTaskWritesRealMemorySummary(t *testing.T) { t.Fatal("expected storage service to be wired") } - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -7661,21 +6950,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -7737,7 +7017,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 := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -7907,22 +7187,13 @@ func TestServiceStartTaskPreservesClipCaptureModeThroughScreenApproval(t *testin t.Fatalf("write clip input failed: %v", err) } - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -7963,25 +7234,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8051,28 +7304,12 @@ func TestServiceStartTaskExplicitScreenAnalyzeKeepsFreshAuthorizationBoundary(t }, }) - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8125,21 +7362,12 @@ func TestServiceStartTaskHandlesClipScreenAnalyzePath(t *testing.T) { t.Fatalf("write clip source failed: %v", err) } - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8174,7 +7402,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 := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -8198,21 +7426,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8250,21 +7469,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8300,21 +7510,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8347,21 +7548,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8381,26 +7568,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 := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -8417,23 +7585,7 @@ func TestServiceSubmitInputInfersScreenAnalyzeFromVisualErrorRequest(t *testing. func TestServiceStartTaskFallsBackWhenScreenCapabilityUnavailable(t *testing.T) { service := newTestService() - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8456,27 +7608,7 @@ func TestServiceStartTaskFallsBackWhenScreenCapabilityUnavailable(t *testing.T) func TestServiceSubmitInputFallbackKeepsExplicitConfirmationWhenScreenCapabilityUnavailable(t *testing.T) { service := newTestService() - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -8503,21 +7635,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8561,21 +7684,12 @@ func TestServiceStartTaskHitsRealMemoryAndRecordsRetrievalHit(t *testing.T) { t.Fatalf("seed memory summary failed: %v", err) } - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8655,21 +7769,12 @@ func TestServiceStartTaskInjectsRetrievedMemoryIntoExecutionInput(t *testing.T) t.Fatalf("seed memory summary failed: %v", err) } - _, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8720,15 +7825,7 @@ func TestServiceConfirmTaskPersistsRetrievalHitOncePerConfirmation(t *testing.T) t.Fatalf("seed memory summary failed: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8806,40 +7903,22 @@ func TestServiceMirrorOverviewFallsBackToStoredFinishedTasks(t *testing.T) { func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { service := newTestService() - _, err := startTaskForTest(service, StartTaskRequestFromParams(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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8880,21 +7959,12 @@ func TestServiceSecuritySummaryUsesRuntimeTaskState(t *testing.T) { func TestServiceSecuritySummaryIncludesRuntimeTokenUsage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "executor-backed token summary") - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -8937,19 +8007,7 @@ func TestServiceBudgetAutoDowngradeSwitchesWorkspaceDeliveryToBubble(t *testing. }) defer unsubscribe() - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -9104,19 +8162,7 @@ func TestServiceBudgetAutoDowngradeDisabledKeepsWorkspaceDelivery(t *testing.T) t.Fatalf("settings update failed: %v", err) } - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -9249,18 +8295,7 @@ func TestExecutionFailureBubbleRedactsSecretBearingProviderMessages(t *testing.T func TestServiceBudgetFallbackSuccessStillAppendsFailureSignal(t *testing.T) { service, _ := newTestServiceWithExecution(t, "executor-backed fallback success signal") - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -9471,21 +8506,12 @@ func TestServiceDashboardModuleCountsRuntimeAndStoredPendingAuthorizations(t *te t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -9542,21 +8568,12 @@ func TestServiceSecuritySummaryCountsRuntimeAndStoredPendingAuthorizations(t *te t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -9606,21 +8623,12 @@ func TestServiceSecurityPendingListMergesRuntimeAndStoredPendingAuthorizations(t t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -9687,21 +8695,12 @@ func TestServiceSecurityPendingListPaginatesMergedPendingAuthorizations(t *testi t.Fatal("expected storage service to be wired") } - runtimeResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -9912,21 +8911,12 @@ func TestServiceSecurityRestoreApplyReturnsStorageErrorWhenApprovalPersistenceFa t.Fatalf("seed output file: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -9953,21 +8943,12 @@ func TestServiceSecurityRestoreApplyReturnsStorageErrorWhenApprovalPersistenceFa func TestServiceDashboardModuleHighlightsIncludeAuditTrail(t *testing.T) { service, _ := newTestServiceWithExecution(t, "dashboard audit trail") - _, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -10254,15 +9235,7 @@ func TestServiceTaskDetailGetFallsBackToStoredRecoveryPointForTask(t *testing.T) t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -10278,7 +9251,7 @@ func TestServiceTaskDetailGetFallsBackToStoredRecoveryPointForTask(t *testing.T) t.Fatalf("write recovery point failed: %v", err) } - result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + result, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10295,15 +9268,7 @@ func TestServiceTaskDetailGetDropsMismatchedSummaryRecoveryPointBeforeStorageFal t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -10327,7 +9292,7 @@ func TestServiceTaskDetailGetDropsMismatchedSummaryRecoveryPointBeforeStorageFal t.Fatalf("write recovery point failed: %v", err) } - result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + result, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10348,21 +9313,12 @@ func TestServiceSecurityRestoreApplyRestoresWorkspaceAndReturnsFormalResult(t *t t.Fatalf("seed original file: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -10468,21 +9424,12 @@ func TestServiceSecurityRestoreApplyReturnsStructuredFailure(t *testing.T) { t.Fatalf("seed original file: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -10560,21 +9507,12 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) t.Fatalf("seed original file: %v", err) } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -10586,7 +9524,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 := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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}) @@ -10618,21 +9556,13 @@ func TestServiceSecurityRestoreApplySupportsPersistedTaskFallback(t *testing.T) func TestServiceTaskDetailGetPreservesStableContractShape(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail delivery") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10666,15 +9596,7 @@ func TestServiceTaskDetailGetPreservesStableContractShape(t *testing.T) { func TestServiceTaskDetailGetNormalizesProtocolCollections(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task detail protocol collections") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -10706,7 +9628,7 @@ func TestServiceTaskDetailGetNormalizesProtocolCollections(t *testing.T) { t.Fatal("expected mirror reference update to succeed") } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -10796,7 +9718,7 @@ func TestServiceTaskDetailGetFallsBackToStoredTaskRun(t *testing.T) { t.Fatalf("save artifact failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -10871,7 +9793,7 @@ func TestServiceTaskDetailGetFallsBackToTaskRunCitationsForScreenTask(t *testing t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -10920,7 +9842,7 @@ func TestServiceTaskDetailGetPrefersStructuredTaskStoreFallback(t *testing.T) { t.Fatalf("replace structured task steps failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -10984,7 +9906,7 @@ func TestServiceTaskDetailGetUsesStructuredSnapshotWithoutReloadingTaskRuns(t *t t.Fatalf("save task run failed: %v", err) } - result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -11065,7 +9987,7 @@ func TestServiceTaskDetailGetReloadsTaskRunWhenStructuredSnapshotIsInvalid(t *te t.Fatalf("overwrite structured task failed: %v", err) } - result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -11172,7 +10094,7 @@ func TestServiceTaskDetailGetStructuredFallbackUsesSessionAndRunStores(t *testin }); err != nil { t.Fatalf("write run failed: %v", err) } - result, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -11222,7 +10144,7 @@ func TestServiceTaskDetailGetPrefersRuntimeStateWhenStructuredRowIsStale(t *test }); err != nil { t.Fatalf("write stale structured task failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -11333,7 +10255,7 @@ func TestServiceTaskDetailGetStructuredFallbackBackfillsTaskRunEvidence(t *testi t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11409,7 +10331,7 @@ func TestServiceTaskDetailGetStructuredFallbackReadsFormalDeliveryFromLoopStore( t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11489,7 +10411,7 @@ func TestServiceTaskDetailGetStructuredFallbackReadsFormalCitationsFromLoopStore t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11610,7 +10532,7 @@ func TestServiceTaskDetailGetStructuredFallbackPrefersFirstClassDeliveryAndCitat t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11691,7 +10613,7 @@ func TestServiceTaskDetailGetStructuredFallbackNormalizesSparseDeliveryPayloadKe t.Fatalf("delete sparse runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11823,7 +10745,7 @@ func TestServiceRestartedStructuredTaskFallsBackToCurrentSnapshotFormalData(t *t t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -11921,7 +10843,7 @@ func TestServiceTaskDetailGetStructuredFallbackRehydratesApprovalRequest(t *test t.Fatalf("write approval request failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12008,7 +10930,7 @@ func TestServiceTaskDetailGetStructuredFallbackRehydratesAuthorizationAndAudit(t t.Fatalf("write structured audit record failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12162,7 +11084,7 @@ func TestServiceTaskDetailGetPrefersStoredScreenFormalObjectsOverRuntimeCompatib t.Fatalf("write stored audit record failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -12233,7 +11155,7 @@ func TestServiceTaskDetailGetPrefersStoredApprovalRequestOverRuntimeCompatibilit t.Fatalf("write stored approval request failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(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) } @@ -12387,7 +11309,7 @@ func TestServiceTaskDetailGetStructuredScreenFallbackPrefersFormalEvidenceObject t.Fatalf("delete runtime task shadow failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12590,7 +11512,7 @@ func TestServiceTaskDetailGetReloadsTaskRunWhenFormalScreenObjectsMaskInvalidSna t.Fatalf("rewrite structured task with invalid snapshot failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12681,7 +11603,7 @@ func TestServiceTaskDetailGetScreenAuditPrefersNewerTerminalGovernanceRecord(t * t.Fatalf("write recovery audit record failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -12760,7 +11682,7 @@ func TestServiceTaskDetailGetStructuredScreenApprovalPrefersFormalApprovalReques t.Fatalf("write formal screen approval request failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13063,21 +11985,12 @@ func TestServiceTaskArtifactOpenPrefersStoredArtifactOverRuntimeCompatibility(t func TestServiceTaskArtifactOpenReturnsArtifactNotFoundWhenTaskExists(t *testing.T) { service, _ := newTestServiceWithExecution(t, "artifact not found") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -13093,21 +12006,12 @@ func TestServiceStartTaskPersistsArtifactsToStore(t *testing.T) { if service.storage == nil { t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -13131,21 +12035,12 @@ func TestServiceStartTaskPersistsArtifactsToStore(t *testing.T) { func TestServiceDeliveryOpenReturnsTaskDeliveryResult(t *testing.T) { service, _ := newTestServiceWithExecution(t, "delivery open task") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -13264,7 +12159,7 @@ func TestServiceResultPageFallbackWithoutFormalDeliveryRowKeepsOpenAndDetailStab t.Fatalf("write structured task failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13343,7 +12238,7 @@ func TestServiceLegacyResultPageSnapshotOverridesBubbleCompatShape(t *testing.T) t.Fatalf("write structured task failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13411,7 +12306,7 @@ func TestServiceResultPagePayloadNormalizationOverridesLegacyBadValues(t *testin t.Fatalf("save delivery result failed: %v", err) } - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13536,21 +12431,12 @@ func TestTaskDeliveryHelpersSupportResultPage(t *testing.T) { func TestServiceTaskArtifactListFallsBackToRuntimeArtifactsWhenStoreEmpty(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -13587,18 +12473,9 @@ func TestServiceTaskArtifactListMergesStoredAndRuntimeArtifacts(t *testing.T) { if service.storage == nil || service.storage.ArtifactStore() == nil { t.Fatal("expected storage service to be wired") } - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -13673,15 +12550,7 @@ func TestServiceTaskArtifactListMergesStoredAndRuntimeArtifacts(t *testing.T) { func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -13701,7 +12570,7 @@ func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t * "delivery_payload": map[string]any{"path": "workspace/runtime-output.txt", "task_id": taskID}, }}) - detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequestFromParams(map[string]any{"task_id": taskID})) + detailResult, err := taskDetailGetForTest(service, TaskDetailGetRequest{TaskID: taskID}) if err != nil { t.Fatalf("task detail get failed: %v", err) } @@ -13735,15 +12604,7 @@ func TestServiceRuntimeArtifactsBackfillStableArtifactIdentifiersWhenMissing(t * func TestServiceTaskControlRejectsInvalidStatusTransition(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -14497,21 +13358,12 @@ func TestDashboardModuleGetIncludesPluginRuntimeSummary(t *testing.T) { func TestDashboardModuleGetTasksIncludesFocusRuntimeSummary(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -14570,21 +13422,12 @@ func TestServiceTaskControlRequiresTaskID(t *testing.T) { func TestServiceTaskControlRequiresAction(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -14601,21 +13444,12 @@ func TestServiceTaskControlRequiresAction(t *testing.T) { func TestServiceTaskControlRejectsFinishedTaskOperations(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -14633,21 +13467,12 @@ func TestServiceTaskControlRejectsFinishedTaskOperations(t *testing.T) { func TestServiceTaskControlReturnsUpdatedTaskAndBubbleForWaitingAuthCancel(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -14992,21 +13817,12 @@ func TestPersistExecutionToolCallEventsFallsBackWhenToolCallIDMissing(t *testing func TestServiceTaskSteerPersistsFollowUpMessage(t *testing.T) { service, _ := newTestServiceWithExecution(t, "task steer") - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -15228,16 +14044,7 @@ func TestServiceSubmitInputRoutesFollowUpIntoExistingTask(t *testing.T) { activeTaskID = activeTask.TaskID activeSessionID := activeTask.SessionID - followUpResult, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -15269,17 +14076,7 @@ func TestServiceSubmitInputQueuesNewTaskWhenActivePromptTaskCannotConsumeSteerin RiskLevel: "green", }) - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -15302,15 +14099,7 @@ func TestServiceSubmitInputQueuesNewTaskWhenActivePromptTaskCannotConsumeSteerin func TestServiceStartTaskNotificationIncludesSessionID(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -15366,18 +14155,7 @@ func TestServiceStartTaskDescribedFileDoesNotAttachToProcessingTask(t *testing.T }) activeTaskID = activeTask.TaskID - followUpResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -15415,19 +14193,7 @@ func TestServiceStartTaskDescribedFileStartsNewTaskWithoutPendingEvidence(t *tes }, }) - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -15466,25 +14232,7 @@ func TestServiceStartTaskShellBallAnchorDoesNotContinuePendingFileTask(t *testin }, }) - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -15563,19 +14311,7 @@ func TestServiceStartTaskConfirmRequiredFileDoesNotContinueProcessingTask(t *tes }) activeTaskID = activeTask.TaskID - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -15623,19 +14359,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesPendingTask(t *testing.T) RiskLevel: "green", }) - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -15683,19 +14407,7 @@ func TestServiceSubmitInputFallsBackWhenContinuationModelTimesOut(t *testing.T) }) start := time.Now() - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -15744,18 +14456,7 @@ func TestServiceSubmitInputConfirmRequiredTextContinuesImplicitPendingTask(t *te }, }) - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -15822,16 +14523,7 @@ func TestServiceSubmitInputPlainTextKeepsConfirmingTaskBehindConfirmation(t *tes }, }) - result, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -15906,14 +14598,7 @@ func TestServiceStartTaskPlainTextImplicitPendingTaskStartsNewWithoutExplicitCon }) activeTaskID = activeTask.TaskID - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16037,23 +14722,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesWaitingInputTask(t *testing }) activeTaskID = activeTask.TaskID - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16114,24 +14783,7 @@ func TestServiceStartTaskStructuredSupplementContinuesPendingTaskWithoutAutoExec }, }) - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16176,23 +14828,7 @@ func TestServiceStartTaskStructuredSupplementResumesWaitingTaskWithConfirmedInte }, }) - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16262,24 +14898,7 @@ func TestServiceStartTaskConfirmRequiredFileContinuesUniquePendingTaskAmongCandi }, }) - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16337,23 +14956,7 @@ func TestServiceStartTaskConfirmRequiredFileStartsNewTaskWithoutPendingEvidence( }) activeTaskID = activeTask.TaskID - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16379,35 +14982,18 @@ func TestServiceStartTaskConfirmRequiredFileStartsNewTaskWithoutPendingEvidence( func TestServiceSubmitInputDoesNotContinueWaitingAuthorizationTask(t *testing.T) { service := newTestService() - startResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -16435,16 +15021,7 @@ func TestServiceSubmitInputDoesNotContinuePausedTask(t *testing.T) { t.Fatalf("pause task failed: %v", err) } - followUpResult, err := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -16466,20 +15043,12 @@ func TestServiceStartTaskWithExplicitIntentDoesNotReuseWaitingTaskWithoutAnchors RiskLevel: "green", }) - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16509,35 +15078,18 @@ func TestServiceSubmitInputStartsNewTaskForUnrelatedRequest(t *testing.T) { }, }) - firstResult, err := startTaskForTest(service, StartTaskRequestFromParams(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 := submitInputForTest(service, SubmitInputRequestFromParams(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) } @@ -16560,20 +15112,12 @@ func TestServiceReusesRecentIdleSessionForNewTopLevelTask(t *testing.T) { RiskLevel: "green", }) - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16586,21 +15130,12 @@ func TestServiceReusesRecentIdleSessionForNewTopLevelTask(t *testing.T) { func TestServiceTaskListIncludesLoopStopReason(t *testing.T) { service := newTestService() for index := 0; index < 2; index++ { - _, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16908,21 +15443,12 @@ func TestServiceTaskListDoesNotDoubleCountRuntimeTasksBackedByLegacyRows(t *test func TestServiceStartTaskWithExecutorWritesWorkspaceDocument(t *testing.T) { service, workspaceRoot := newTestServiceWithExecution(t, "第一点\n第二点\n第三点") - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -16970,19 +15496,10 @@ func TestServiceStartTaskWithExecutorWritesWorkspaceDocument(t *testing.T) { func TestServiceStartTaskWithExecutorReturnsGeneratedBubble(t *testing.T) { service, _ := newTestServiceWithExecution(t, "这段内容主要在解释当前问题的原因和处理方向。") - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -17021,21 +15538,12 @@ func TestServiceStartTaskWithExecutorDeliversPageReadResultPage(t *testing.T) { Source: "playwright_sidecar", }}) - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -17101,23 +15609,14 @@ func TestServiceStartTaskWithExecutorDeliversPageSearchResultPage(t *testing.T) Source: "playwright_sidecar", }}) - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -17156,21 +15655,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -17205,23 +15695,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 := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -17260,21 +15741,12 @@ func TestServiceMediaWorkerPropagatesArtifactsAndWorkerEventPayload(t *testing.T func TestServiceStartTaskWithExecutorPageReadFailureUsesUnifiedError(t *testing.T) { service, _ := newTestServiceWithExecutionAndPlaywright(t, "unused", platform.LocalExecutionBackend{}, nil, stubPlaywrightClient{err: tools.ErrPlaywrightSidecarFailed}) - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } @@ -17336,21 +15808,12 @@ func TestServiceStartTaskWithRealLocalPageReadDelivery(t *testing.T) { }() service, _ := newTestServiceWithExecutionAndPlaywright(t, "unused", platform.LocalExecutionBackend{}, nil, localHTTPPlaywrightClient{}) - result, err := startTaskForTest(service, StartTaskRequestFromParams(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) } From c94cb2a83b81aa275ae0ca88c80a6be6ebd21ae9 Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Mon, 11 May 2026 02:38:37 +0800 Subject: [PATCH 30/41] fix(orchestrator): keep runtime approval entry responses valid --- .../internal/orchestrator/governance.go | 6 +--- .../internal/orchestrator/service_test.go | 34 +++++++++---------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/services/local-service/internal/orchestrator/governance.go b/services/local-service/internal/orchestrator/governance.go index d03b1454d..ac29cf237 100644 --- a/services/local-service/internal/orchestrator/governance.go +++ b/services/local-service/internal/orchestrator/governance.go @@ -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 diff --git a/services/local-service/internal/orchestrator/service_test.go b/services/local-service/internal/orchestrator/service_test.go index ee8514237..c0190b006 100644 --- a/services/local-service/internal/orchestrator/service_test.go +++ b/services/local-service/internal/orchestrator/service_test.go @@ -4309,15 +4309,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": "去 https://example.com 看一下页面内容", - }, - "intent": map[string]any{ + 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{ "name": "agent_loop", "arguments": map[string]any{}, }, @@ -4399,15 +4399,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{}, }, From 8d9783be26c3a53eec35900120d2d8319bba278b Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Mon, 11 May 2026 02:38:51 +0800 Subject: [PATCH 31/41] fix(orchestrator): build task detail dto directly --- .../orchestrator/protocol_dto_test.go | 372 ------------------ .../orchestrator/protocol_response_dto.go | 174 -------- .../internal/orchestrator/task_query.go | 258 ++++++++++-- 3 files changed, 235 insertions(+), 569 deletions(-) diff --git a/services/local-service/internal/orchestrator/protocol_dto_test.go b/services/local-service/internal/orchestrator/protocol_dto_test.go index 8226900d3..227f14e71 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test.go @@ -100,118 +100,6 @@ func TestTaskEntryResponseMapNormalizesUnknownFields(t *testing.T) { } } -func TestTaskDetailGetResponseMapNormalizesUnknownFields(t *testing.T) { - response, err := newTaskDetailGetResponse(map[string]any{ - "task": map[string]any{ - "task_id": "task_detail_123", - "session_id": "sess_123", - "title": "Explain error", - "source_type": "error", - "status": "completed", - "intent": nil, - "current_step": "generate_output", - "risk_level": "green", - "started_at": "2026-05-10T00:00:00Z", - "updated_at": "2026-05-10T00:00:01Z", - "finished_at": "2026-05-10T00:00:02Z", - "unknown": "drop-me", - }, - "timeline": []map[string]any{}, - "delivery_result": map[string]any{ - "type": "bubble", - "title": "Explanation", - "preview_text": "Summary", - "payload": map[string]any{ - "path": nil, - "url": nil, - "task_id": "task_detail_123", - "unknown": "drop-me", - }, - "unknown": "drop-me", - }, - "artifacts": []map[string]any{}, - "citations": []map[string]any{}, - "mirror_references": []map[string]any{}, - "approval_request": nil, - "authorization_record": nil, - "audit_record": nil, - "security_summary": map[string]any{"security_status": "normal", "risk_level": "green", "pending_authorizations": 0, "latest_restore_point": nil, "unknown": "drop-me"}, - "runtime_summary": map[string]any{"loop_stop_reason": nil, "events_count": 0, "latest_event_type": nil, "active_steering_count": 0, "latest_failure_code": nil, "latest_failure_category": nil, "latest_failure_summary": nil, "observation_signals": []string{}, "unknown": "drop-me"}, - "unknown": "drop-me", - }) - if err != nil { - t.Fatalf("build task detail response failed: %v", err) - } - - mapped := response.Map() - if _, ok := mapped["unknown"]; ok { - t.Fatalf("expected typed detail normalization to drop unknown top-level fields, got %+v", mapped) - } - deliveryResult := mapValue(mapped, "delivery_result") - if _, ok := deliveryResult["unknown"]; ok { - t.Fatalf("expected typed detail normalization to drop unknown delivery_result fields, got %+v", deliveryResult) - } - payload := mapValue(deliveryResult, "payload") - if _, ok := payload["unknown"]; ok { - t.Fatalf("expected typed detail normalization to drop unknown delivery payload fields, got %+v", payload) - } - securitySummary := mapValue(mapped, "security_summary") - if _, ok := securitySummary["unknown"]; ok { - t.Fatalf("expected typed detail normalization to drop unknown security summary fields, got %+v", securitySummary) - } - runtimeSummary := mapValue(mapped, "runtime_summary") - if _, ok := runtimeSummary["unknown"]; ok { - t.Fatalf("expected typed detail normalization to drop unknown runtime summary fields, got %+v", runtimeSummary) - } -} - -func TestTaskDetailGetResponseMapDropsAuthorizationRecordRunID(t *testing.T) { - response, err := newTaskDetailGetResponse(map[string]any{ - "task": map[string]any{ - "task_id": "task_detail_auth", - "session_id": "sess_auth", - "title": "Inspect approval history", - "source_type": "screen_capture", - "status": "completed", - "current_step": "generate_output", - "risk_level": "yellow", - "started_at": "2026-05-10T00:00:00Z", - "updated_at": "2026-05-10T00:00:01Z", - }, - "timeline": []map[string]any{}, - "delivery_result": nil, - "artifacts": []map[string]any{}, - "citations": []map[string]any{}, - "mirror_references": []map[string]any{}, - "approval_request": nil, - "authorization_record": map[string]any{ - "authorization_record_id": "auth_001", - "task_id": "task_detail_auth", - "run_id": "run_hidden", - "approval_id": "appr_001", - "decision": "allow_once", - "remember_rule": false, - "operator": "user", - "created_at": "2026-05-10T00:00:02Z", - }, - "audit_record": nil, - "security_summary": map[string]any{"security_status": "normal", "risk_level": "yellow", "pending_authorizations": 0, "latest_restore_point": nil}, - "runtime_summary": map[string]any{"events_count": 0, "active_steering_count": 0, "observation_signals": []string{}}, - }) - if err != nil { - t.Fatalf("build task detail response failed: %v", err) - } - - mapped := response.Map() - authorizationRecord := mapValue(mapped, "authorization_record") - if _, ok := authorizationRecord["run_id"]; ok { - t.Fatalf("expected authorization_record run_id to stay out of the stable dto, got %+v", authorizationRecord) - } - if stringValue(authorizationRecord, "authorization_record_id", "") != "auth_001" { - t.Fatalf("expected typed detail normalization to preserve declared authorization fields, got %+v", authorizationRecord) - } -} - func TestTaskEntryResponseIgnoresUnknownNonJSONFields(t *testing.T) { response, err := newTaskEntryResponse(map[string]any{ "task": map[string]any{ @@ -236,186 +124,6 @@ func TestTaskEntryResponseIgnoresUnknownNonJSONFields(t *testing.T) { } } -func TestTaskDetailGetResponseRejectsMalformedDeclaredObjects(t *testing.T) { - _, err := newTaskDetailGetResponse(map[string]any{"task": "not-an-object"}) - if err == nil { - t.Fatal("expected malformed declared task object to fail response dto construction") - } -} - -func TestTaskDetailGetResponseRejectsMissingRequiredSummaryObjects(t *testing.T) { - basePayload := map[string]any{ - "task": map[string]any{ - "task_id": "task_detail_required_objects", - "title": "Required task detail objects", - "source_type": "floating_ball", - "status": "completed", - "current_step": "deliver_result", - "risk_level": "green", - "updated_at": "2026-05-10T00:00:01Z", - }, - "timeline": []map[string]any{}, - "delivery_result": nil, - "artifacts": []map[string]any{}, - "citations": []map[string]any{}, - "mirror_references": []map[string]any{}, - "approval_request": nil, - "authorization_record": nil, - "audit_record": nil, - "security_summary": map[string]any{ - "security_status": "safe", - "risk_level": "green", - "pending_authorizations": 0, - "latest_restore_point": nil, - }, - "runtime_summary": map[string]any{ - "events_count": 0, - "active_steering_count": 0, - "observation_signals": []string{}, - "latest_event_type": nil, - "latest_failure_code": nil, - "latest_failure_category": nil, - "latest_failure_summary": nil, - "loop_stop_reason": nil, - }, - } - - testCases := []struct { - name string - missing string - }{ - {name: "missing security_summary", missing: "security_summary"}, - {name: "missing runtime_summary", missing: "runtime_summary"}, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - payload := cloneMap(basePayload) - delete(payload, testCase.missing) - if _, err := newTaskDetailGetResponse(payload); err == nil { - t.Fatalf("expected missing %s to fail task detail dto construction", testCase.missing) - } - }) - } -} - -func TestTaskDetailGetResponseRejectsMissingRequiredSummaryFields(t *testing.T) { - testCases := []struct { - name string - payload map[string]any - }{ - { - name: "security_summary pending_authorizations", - payload: map[string]any{ - "task": map[string]any{ - "task_id": "task_detail_missing_security_field", - "title": "Missing security field", - "source_type": "floating_ball", - "status": "completed", - "current_step": "deliver_result", - "risk_level": "green", - "updated_at": "2026-05-10T00:00:01Z", - }, - "timeline": []map[string]any{}, - "delivery_result": nil, - "artifacts": []map[string]any{}, - "citations": []map[string]any{}, - "mirror_references": []map[string]any{}, - "approval_request": nil, - "authorization_record": nil, - "audit_record": nil, - "security_summary": map[string]any{ - "security_status": "safe", - "risk_level": "green", - "latest_restore_point": nil, - }, - "runtime_summary": map[string]any{ - "events_count": 0, - "active_steering_count": 0, - "observation_signals": []string{}, - }, - }, - }, - { - name: "runtime_summary observation_signals", - payload: map[string]any{ - "task": map[string]any{ - "task_id": "task_detail_missing_runtime_field", - "title": "Missing runtime field", - "source_type": "floating_ball", - "status": "completed", - "current_step": "deliver_result", - "risk_level": "green", - "updated_at": "2026-05-10T00:00:01Z", - }, - "timeline": []map[string]any{}, - "delivery_result": nil, - "artifacts": []map[string]any{}, - "citations": []map[string]any{}, - "mirror_references": []map[string]any{}, - "approval_request": nil, - "authorization_record": nil, - "audit_record": nil, - "security_summary": map[string]any{ - "security_status": "safe", - "risk_level": "green", - "pending_authorizations": 0, - "latest_restore_point": nil, - }, - "runtime_summary": map[string]any{ - "events_count": 0, - "active_steering_count": 0, - }, - }, - }, - { - name: "delivery_result payload", - payload: map[string]any{ - "task": map[string]any{ - "task_id": "task_detail_missing_delivery_payload", - "title": "Missing delivery payload", - "source_type": "floating_ball", - "status": "completed", - "current_step": "deliver_result", - "risk_level": "green", - "updated_at": "2026-05-10T00:00:01Z", - }, - "timeline": []map[string]any{}, - "delivery_result": map[string]any{ - "type": "bubble", - "title": "Result", - "preview_text": "Preview", - }, - "artifacts": []map[string]any{}, - "citations": []map[string]any{}, - "mirror_references": []map[string]any{}, - "approval_request": nil, - "authorization_record": nil, - "audit_record": nil, - "security_summary": map[string]any{ - "security_status": "safe", - "risk_level": "green", - "pending_authorizations": 0, - "latest_restore_point": nil, - }, - "runtime_summary": map[string]any{ - "events_count": 0, - "active_steering_count": 0, - "observation_signals": []string{}, - }, - }, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - if _, err := newTaskDetailGetResponse(testCase.payload); err == nil { - t.Fatalf("expected missing required declared field to fail for %s", testCase.name) - } - }) - } -} - func TestTaskEntryResponseRejectsMissingRequiredDeclaredFields(t *testing.T) { testCases := []struct { name string @@ -466,83 +174,3 @@ func TestTaskEntryResponseRejectsMissingRequiredDeclaredFields(t *testing.T) { }) } } - -func TestTaskDetailGetResponseRejectsMissingRequiredDeclaredFields(t *testing.T) { - testCases := []struct { - name string - payload map[string]any - }{ - { - name: "task.task_id", - payload: map[string]any{ - "task": map[string]any{ - "title": "Missing task id", - "source_type": "floating_ball", - "status": "completed", - "current_step": "deliver_result", - "risk_level": "green", - "updated_at": "2026-05-10T00:00:01Z", - }, - "timeline": []map[string]any{}, - "delivery_result": nil, - "artifacts": []map[string]any{}, - "citations": []map[string]any{}, - "mirror_references": []map[string]any{}, - "approval_request": nil, - "authorization_record": nil, - "audit_record": nil, - "security_summary": map[string]any{ - "security_status": "safe", - "risk_level": "green", - "pending_authorizations": 0, - "latest_restore_point": nil, - }, - "runtime_summary": map[string]any{ - "events_count": 0, - "active_steering_count": 0, - "observation_signals": []string{}, - }, - }, - }, - { - name: "timeline array", - payload: map[string]any{ - "task": map[string]any{ - "task_id": "task_missing_timeline", - "title": "Missing timeline", - "source_type": "floating_ball", - "status": "completed", - "current_step": "deliver_result", - "risk_level": "green", - "updated_at": "2026-05-10T00:00:01Z", - }, - "delivery_result": nil, - "artifacts": []map[string]any{}, - "citations": []map[string]any{}, - "mirror_references": []map[string]any{}, - "approval_request": nil, - "authorization_record": nil, - "audit_record": nil, - "security_summary": map[string]any{ - "security_status": "safe", - "risk_level": "green", - "pending_authorizations": 0, - "latest_restore_point": nil, - }, - "runtime_summary": map[string]any{ - "events_count": 0, - "active_steering_count": 0, - "observation_signals": []string{}, - }, - }, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - if _, err := newTaskDetailGetResponse(testCase.payload); err == nil { - t.Fatalf("expected missing required declared field to fail for %s", testCase.name) - } - }) - } -} diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index 10670a67d..db631d857 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -318,81 +318,6 @@ func newTaskEntryResponse(payload map[string]any) (TaskEntryResponse, error) { }, nil } -func newTaskDetailGetResponse(payload map[string]any) (TaskDetailGetResponse, error) { - taskPayload, ok, err := protocolMapField(payload, "task") - if err != nil { - return TaskDetailGetResponse{}, err - } - if !ok { - return TaskDetailGetResponse{}, fmt.Errorf("task must be object") - } - task, err := taskDTOFromMap(taskPayload) - if err != nil { - return TaskDetailGetResponse{}, fmt.Errorf("task: %w", err) - } - timeline, err := taskStepDTOListFromMap(payload, "timeline") - if err != nil { - return TaskDetailGetResponse{}, err - } - deliveryResult, err := deliveryResultDTOPointerFromMap(payload, "delivery_result") - if err != nil { - return TaskDetailGetResponse{}, err - } - artifacts, err := artifactDTOListFromMap(payload, "artifacts") - if err != nil { - return TaskDetailGetResponse{}, err - } - citations, err := citationDTOListFromMap(payload, "citations") - if err != nil { - return TaskDetailGetResponse{}, err - } - mirrorReferences, err := mirrorReferenceDTOListFromMap(payload, "mirror_references") - if err != nil { - return TaskDetailGetResponse{}, err - } - approvalRequest, err := approvalRequestDTOPointerFromMap(payload, "approval_request") - if err != nil { - return TaskDetailGetResponse{}, err - } - authorizationRecord, err := authorizationRecordDTOPointerFromMap(payload, "authorization_record") - if err != nil { - return TaskDetailGetResponse{}, err - } - auditRecord, err := auditRecordDTOPointerFromMap(payload, "audit_record") - if err != nil { - return TaskDetailGetResponse{}, err - } - securitySummaryPayload, err := requireProtocolMapField(payload, "security_summary") - if err != nil { - return TaskDetailGetResponse{}, err - } - securitySummary, err := securitySummaryDTOFromMap(securitySummaryPayload) - if err != nil { - return TaskDetailGetResponse{}, fmt.Errorf("security_summary: %w", err) - } - runtimeSummaryPayload, err := requireProtocolMapField(payload, "runtime_summary") - if err != nil { - return TaskDetailGetResponse{}, err - } - runtimeSummary, err := runtimeSummaryDTOFromMap(runtimeSummaryPayload) - if err != nil { - return TaskDetailGetResponse{}, fmt.Errorf("runtime_summary: %w", err) - } - return TaskDetailGetResponse{ - Task: task, - Timeline: timeline, - DeliveryResult: deliveryResult, - Artifacts: artifacts, - Citations: citations, - MirrorReferences: mirrorReferences, - ApprovalRequest: approvalRequest, - AuthorizationRecord: authorizationRecord, - AuditRecord: auditRecord, - SecuritySummary: securitySummary, - RuntimeSummary: runtimeSummary, - }, 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 { @@ -627,22 +552,6 @@ func deliveryPayloadDTOFromMap(values map[string]any) (DeliveryPayloadDTO, error return DeliveryPayloadDTO{Path: path, URL: url, TaskID: taskID}, nil } -func taskStepDTOListFromMap(values map[string]any, key string) ([]TaskStepDTO, error) { - items, err := requireProtocolMapSliceField(values, key) - if err != nil { - return nil, err - } - result := make([]TaskStepDTO, 0, len(items)) - for index, item := range items { - dto, err := taskStepDTOFromMap(item) - if err != nil { - return nil, fmt.Errorf("%s[%d]: %w", key, index, err) - } - result = append(result, dto) - } - return result, nil -} - func taskStepDTOFromMap(values map[string]any) (TaskStepDTO, error) { stepID, err := requireProtocolStringField(values, "step_id") if err != nil { @@ -683,22 +592,6 @@ func taskStepDTOFromMap(values map[string]any) (TaskStepDTO, error) { }, nil } -func artifactDTOListFromMap(values map[string]any, key string) ([]ArtifactDTO, error) { - items, err := requireProtocolMapSliceField(values, key) - if err != nil { - return nil, err - } - result := make([]ArtifactDTO, 0, len(items)) - for index, item := range items { - dto, err := artifactDTOFromMap(item) - if err != nil { - return nil, fmt.Errorf("%s[%d]: %w", key, index, err) - } - result = append(result, dto) - } - return result, nil -} - func artifactDTOFromMap(values map[string]any) (ArtifactDTO, error) { artifactID, err := requireProtocolStringField(values, "artifact_id") if err != nil { @@ -734,22 +627,6 @@ func artifactDTOFromMap(values map[string]any) (ArtifactDTO, error) { }, nil } -func citationDTOListFromMap(values map[string]any, key string) ([]CitationDTO, error) { - items, err := requireProtocolMapSliceField(values, key) - if err != nil { - return nil, err - } - result := make([]CitationDTO, 0, len(items)) - for index, item := range items { - dto, err := citationDTOFromMap(item) - if err != nil { - return nil, fmt.Errorf("%s[%d]: %w", key, index, err) - } - result = append(result, dto) - } - return result, nil -} - func citationDTOFromMap(values map[string]any) (CitationDTO, error) { citationID, err := requireProtocolStringField(values, "citation_id") if err != nil { @@ -810,22 +687,6 @@ func citationDTOFromMap(values map[string]any) (CitationDTO, error) { }, nil } -func mirrorReferenceDTOListFromMap(values map[string]any, key string) ([]MirrorReferenceDTO, error) { - items, err := requireProtocolMapSliceField(values, key) - if err != nil { - return nil, err - } - result := make([]MirrorReferenceDTO, 0, len(items)) - for index, item := range items { - dto, err := mirrorReferenceDTOFromMap(item) - if err != nil { - return nil, fmt.Errorf("%s[%d]: %w", key, index, err) - } - result = append(result, dto) - } - return result, nil -} - func mirrorReferenceDTOFromMap(values map[string]any) (MirrorReferenceDTO, error) { memoryID, err := requireProtocolStringField(values, "memory_id") if err != nil { @@ -1155,41 +1016,6 @@ func requireProtocolMapField(values map[string]any, key string) (map[string]any, return value, nil } -func protocolMapSliceField(values map[string]any, key string) ([]map[string]any, error) { - rawValue, ok := values[key] - if !ok || rawValue == nil { - return nil, nil - } - switch value := rawValue.(type) { - case []map[string]any: - result := make([]map[string]any, 0, len(value)) - for _, item := range value { - result = append(result, cloneProtocolMap(item)) - } - return result, nil - case []any: - result := make([]map[string]any, 0, len(value)) - for index, rawItem := range value { - item, ok := rawItem.(map[string]any) - if !ok { - return nil, protocolIndexedTypeError(key, index, "object", rawItem) - } - result = append(result, cloneProtocolMap(item)) - } - return result, nil - default: - return nil, protocolTypeError(key, "array of objects", rawValue) - } -} - -func requireProtocolMapSliceField(values map[string]any, key string) ([]map[string]any, error) { - rawValue, ok := values[key] - if !ok || rawValue == nil { - return nil, fmt.Errorf("%s must be array of objects", key) - } - return protocolMapSliceField(values, key) -} - func protocolStringField(values map[string]any, key string) (string, error) { rawValue, ok := values[key] if !ok || rawValue == nil { diff --git a/services/local-service/internal/orchestrator/task_query.go b/services/local-service/internal/orchestrator/task_query.go index 635660927..ea7f0319b 100644 --- a/services/local-service/internal/orchestrator/task_query.go +++ b/services/local-service/internal/orchestrator/task_query.go @@ -39,21 +39,17 @@ func (s *Service) TaskList(params map[string]any) (map[string]any, error) { // 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(request TaskDetailGetRequest) (TaskDetailGetResponse, error) { - return s.TaskDetailGetFromParams(request.ProtocolParamsMap()) + return s.taskDetailGet(request) } -// TaskDetailGetFromParams lets the RPC layer pass its normalized stable payload -// straight into task-detail assembly without rebuilding the same protocol map. +// 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) { - response, err := s.taskDetailGet(params) - if err != nil { - return TaskDetailGetResponse{}, err - } - return newTaskDetailGetResponse(response) + return s.TaskDetailGet(TaskDetailGetRequestFromParams(params)) } -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) { + taskID := request.TaskID task, ok := s.taskDetailFromStorage(taskID) if runtimeTask, runtimeOK := s.runEngine.TaskDetail(taskID); runtimeOK { if ok { @@ -64,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) @@ -125,21 +121,237 @@ 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 } +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 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. From 97e8d861bc3a0f2aaa488a9c2368793a0ff1d4af Mon Sep 17 00:00:00 2001 From: Blackcloudss <3444535358@qq.com> Date: Mon, 11 May 2026 02:47:19 +0800 Subject: [PATCH 32/41] fix(orchestrator): remove stale task detail map helpers --- .../orchestrator/protocol_response_dto.go | 85 ------------------- .../internal/orchestrator/task_delivery.go | 8 -- .../orchestrator/task_query_storage.go | 17 ---- 3 files changed, 110 deletions(-) diff --git a/services/local-service/internal/orchestrator/protocol_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index db631d857..9c0d974b7 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -552,46 +552,6 @@ func deliveryPayloadDTOFromMap(values map[string]any) (DeliveryPayloadDTO, error return DeliveryPayloadDTO{Path: path, URL: url, TaskID: taskID}, nil } -func taskStepDTOFromMap(values map[string]any) (TaskStepDTO, error) { - stepID, err := requireProtocolStringField(values, "step_id") - if err != nil { - return TaskStepDTO{}, err - } - taskID, err := requireProtocolStringField(values, "task_id") - if err != nil { - return TaskStepDTO{}, err - } - name, err := requireProtocolStringField(values, "name") - if err != nil { - return TaskStepDTO{}, err - } - status, err := requireProtocolStringField(values, "status") - if err != nil { - return TaskStepDTO{}, err - } - orderIndex, err := requireProtocolIntField(values, "order_index") - if err != nil { - return TaskStepDTO{}, err - } - inputSummary, err := requireProtocolStringField(values, "input_summary") - if err != nil { - return TaskStepDTO{}, err - } - outputSummary, err := requireProtocolStringField(values, "output_summary") - if err != nil { - return TaskStepDTO{}, err - } - return TaskStepDTO{ - StepID: stepID, - TaskID: taskID, - Name: name, - Status: status, - OrderIndex: orderIndex, - InputSummary: inputSummary, - OutputSummary: outputSummary, - }, nil -} - func artifactDTOFromMap(values map[string]any) (ArtifactDTO, error) { artifactID, err := requireProtocolStringField(values, "artifact_id") if err != nil { @@ -703,21 +663,6 @@ func mirrorReferenceDTOFromMap(values map[string]any) (MirrorReferenceDTO, error return MirrorReferenceDTO{MemoryID: memoryID, Reason: reason, Summary: summary}, nil } -func approvalRequestDTOPointerFromMap(values map[string]any, key string) (*ApprovalRequestDTO, error) { - payload, ok, err := protocolMapField(values, key) - if err != nil { - return nil, err - } - if !ok { - return nil, nil - } - approvalRequest, err := approvalRequestDTOFromMap(payload) - if err != nil { - return nil, fmt.Errorf("%s: %w", key, err) - } - return &approvalRequest, nil -} - func approvalRequestDTOFromMap(values map[string]any) (ApprovalRequestDTO, error) { approvalID, err := requireProtocolStringField(values, "approval_id") if err != nil { @@ -763,21 +708,6 @@ func approvalRequestDTOFromMap(values map[string]any) (ApprovalRequestDTO, error }, nil } -func authorizationRecordDTOPointerFromMap(values map[string]any, key string) (*AuthorizationRecordDTO, error) { - payload, ok, err := protocolMapField(values, key) - if err != nil { - return nil, err - } - if !ok { - return nil, nil - } - authorizationRecord, err := authorizationRecordDTOFromMap(payload) - if err != nil { - return nil, fmt.Errorf("%s: %w", key, err) - } - return &authorizationRecord, nil -} - func authorizationRecordDTOFromMap(values map[string]any) (AuthorizationRecordDTO, error) { recordID, err := requireProtocolStringField(values, "authorization_record_id") if err != nil { @@ -818,21 +748,6 @@ func authorizationRecordDTOFromMap(values map[string]any) (AuthorizationRecordDT }, nil } -func auditRecordDTOPointerFromMap(values map[string]any, key string) (*AuditRecordDTO, error) { - payload, ok, err := protocolMapField(values, key) - if err != nil { - return nil, err - } - if !ok { - return nil, nil - } - auditRecord, err := auditRecordDTOFromMap(payload) - if err != nil { - return nil, fmt.Errorf("%s: %w", key, err) - } - return &auditRecord, nil -} - func auditRecordDTOFromMap(values map[string]any) (AuditRecordDTO, error) { auditID, err := requireProtocolStringField(values, "audit_id") if err != nil { 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_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{ From 827e841296766f70f3b9c65fc5e60c3d51420210 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 11 May 2026 01:09:08 +0000 Subject: [PATCH 33/41] fix(local-service): tighten task entry dto boundaries Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/orchestrator/governance.go | 37 +++++---- .../internal/orchestrator/input_submit.go | 51 ++++++------ .../internal/orchestrator/screen_analyze.go | 30 +++---- .../internal/orchestrator/service_test.go | 78 +++++++++++++++++++ .../internal/orchestrator/social_chat.go | 8 +- .../internal/orchestrator/task_confirm.go | 42 +++++----- .../orchestrator/task_continuation.go | 48 ++++-------- .../internal/orchestrator/task_control.go | 2 +- .../orchestrator/task_entry_response.go | 27 +++++-- .../internal/orchestrator/task_query.go | 37 +++++++-- .../internal/orchestrator/task_start.go | 30 ++++--- .../internal/perception/service.go | 3 +- .../internal/perception/service_test.go | 2 +- .../internal/taskcontext/service.go | 8 +- .../internal/taskcontext/service_test.go | 4 +- .../internal/urlutil/context_url.go | 23 ++++++ .../internal/urlutil/context_url_test.go | 17 ++++ 17 files changed, 287 insertions(+), 160 deletions(-) create mode 100644 services/local-service/internal/urlutil/context_url.go create mode 100644 services/local-service/internal/urlutil/context_url_test.go diff --git a/services/local-service/internal/orchestrator/governance.go b/services/local-service/internal/orchestrator/governance.go index ac29cf237..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) { @@ -276,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{} @@ -287,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 c1b2e02ef..6dfa96459 100644 --- a/services/local-service/internal/orchestrator/input_submit.go +++ b/services/local-service/internal/orchestrator/input_submit.go @@ -18,14 +18,10 @@ func (s *Service) SubmitInput(request SubmitInputRequest) (TaskEntryResponse, er // 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) { - response, err := s.submitInput(params) - if err != nil { - return TaskEntryResponse{}, err - } - return newTaskEntryResponse(response) + return s.submitInput(params) } -func (s *Service) submitInput(params map[string]any) (map[string]any, error) { +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 @@ -33,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 { @@ -60,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, @@ -71,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) { @@ -98,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", ""), @@ -120,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 @@ -148,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/screen_analyze.go b/services/local-service/internal/orchestrator/screen_analyze.go index ddf51b978..343e3dc74 100644 --- a/services/local-service/internal/orchestrator/screen_analyze.go +++ b/services/local-service/internal/orchestrator/screen_analyze.go @@ -36,9 +36,9 @@ type screenAnalyzeCandidateIntentArguments struct { TargetObject string `json:"target_object"` } -func (s *Service) handleScreenAnalyzeStart(params map[string]any, snapshot taskcontext.TaskContextSnapshot, explicitIntent map[string]any) (map[string]any, bool, error) { +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{ @@ -57,38 +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 } 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, approvalState.PendingExecution.ImpactScope.mapValue()); err != nil { - return nil, false, err + 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) } diff --git a/services/local-service/internal/orchestrator/service_test.go b/services/local-service/internal/orchestrator/service_test.go index c0190b006..2b70d00b7 100644 --- a/services/local-service/internal/orchestrator/service_test.go +++ b/services/local-service/internal/orchestrator/service_test.go @@ -6318,6 +6318,84 @@ func TestServiceTaskDetailGetDropsStaleApprovalAnchorOutsideWaitingAuth(t *testi } } +func TestServiceTaskDetailGetDerivesInterceptedStatusFromAuthorizationDecision(t *testing.T) { + service := newTestService() + + 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, + }, + }}) + 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 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() 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 afe59e584..a175d7a68 100644 --- a/services/local-service/internal/orchestrator/task_confirm.go +++ b/services/local-service/internal/orchestrator/task_confirm.go @@ -36,20 +36,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 } @@ -64,18 +64,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 @@ -90,11 +90,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_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 ea7f0319b..85bd45a1b 100644 --- a/services/local-service/internal/orchestrator/task_query.go +++ b/services/local-service/internal/orchestrator/task_query.go @@ -94,13 +94,6 @@ func (s *Service) taskDetailGet(request TaskDetailGetRequest) (TaskDetailGetResp if approvalRequest != nil { securitySummary["pending_authorizations"] = 1 } - if strings.TrimSpace(stringValue(securitySummary, "security_status", "")) == "" { - if approvalRequest != nil { - securitySummary["security_status"] = "pending_confirmation" - } else { - securitySummary["security_status"] = "normal" - } - } if strings.TrimSpace(stringValue(securitySummary, "risk_level", "")) == "" { securitySummary["risk_level"] = firstNonEmptyString( stringValue(approvalRequest, "risk_level", ""), @@ -108,6 +101,9 @@ func (s *Service) taskDetailGet(request TaskDetailGetRequest) (TaskDetailGetResp ) } 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 { @@ -173,6 +169,33 @@ func (s *Service) taskDetailGet(request TaskDetailGetRequest) (TaskDetailGetResp }, 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" + } + 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, diff --git a/services/local-service/internal/orchestrator/task_start.go b/services/local-service/internal/orchestrator/task_start.go index 0547b6942..f4a6131a5 100644 --- a/services/local-service/internal/orchestrator/task_start.go +++ b/services/local-service/internal/orchestrator/task_start.go @@ -21,14 +21,10 @@ func (s *Service) StartTask(request StartTaskRequest) (TaskEntryResponse, error) // 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) { - response, err := s.startTask(params) - if err != nil { - return TaskEntryResponse{}, err - } - return newTaskEntryResponse(response) + return s.startTask(params) } -func (s *Service) startTask(params map[string]any) (map[string]any, error) { +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 @@ -75,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, @@ -86,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) } @@ -105,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) } @@ -140,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 @@ -166,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/perception/service.go b/services/local-service/internal/perception/service.go index 6b479aa11..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")), diff --git a/services/local-service/internal/perception/service_test.go b/services/local-service/internal/perception/service_test.go index 4899577bd..6448e279b 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", diff --git a/services/local-service/internal/taskcontext/service.go b/services/local-service/internal/taskcontext/service.go index da440a819..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. @@ -96,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 abb60e709..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 ", 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..417297577 --- /dev/null +++ b/services/local-service/internal/urlutil/context_url.go @@ -0,0 +1,23 @@ +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 { + return trimmed + } + parsed.User = nil + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() +} 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..f1ca3b3d4 --- /dev/null +++ b/services/local-service/internal/urlutil/context_url_test.go @@ -0,0 +1,17 @@ +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) + } +} From 86909d5b6f14c1d72af43499f1a97f20ddc4c4fd Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 11 May 2026 01:19:31 +0000 Subject: [PATCH 34/41] fix(orchestrator): drop dead delivery helper Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- services/local-service/internal/orchestrator/utilities.go | 7 ------- 1 file changed, 7 deletions(-) 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 { From ef7a9f927e91daa2576b895e86e02bd46edab6e2 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 11 May 2026 02:36:36 +0000 Subject: [PATCH 35/41] fix(local-service): restore intercepted fallback and drop malformed urls Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/orchestrator/service_test.go | 45 +++++++++++++++++++ .../internal/orchestrator/task_query.go | 7 +++ .../internal/perception/service_test.go | 11 +++++ .../internal/urlutil/context_url.go | 4 +- .../internal/urlutil/context_url_test.go | 7 +++ 5 files changed, 73 insertions(+), 1 deletion(-) diff --git a/services/local-service/internal/orchestrator/service_test.go b/services/local-service/internal/orchestrator/service_test.go index 2b70d00b7..25e5879ff 100644 --- a/services/local-service/internal/orchestrator/service_test.go +++ b/services/local-service/internal/orchestrator/service_test.go @@ -6354,6 +6354,51 @@ func TestServiceTaskDetailGetDerivesInterceptedStatusFromAuthorizationDecision(t } } +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 { diff --git a/services/local-service/internal/orchestrator/task_query.go b/services/local-service/internal/orchestrator/task_query.go index 85bd45a1b..18e26149f 100644 --- a/services/local-service/internal/orchestrator/task_query.go +++ b/services/local-service/internal/orchestrator/task_query.go @@ -179,6 +179,13 @@ func deriveTaskDetailSecurityStatus(task runengine.TaskRecord, approvalRequest, 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": diff --git a/services/local-service/internal/perception/service_test.go b/services/local-service/internal/perception/service_test.go index 6448e279b..ce41da7c2 100644 --- a/services/local-service/internal/perception/service_test.go +++ b/services/local-service/internal/perception/service_test.go @@ -53,6 +53,17 @@ func TestCaptureContextSignalsNormalizesNestedSignals(t *testing.T) { } } +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) { snapshot := SignalSnapshot{ Source: "floating_ball", diff --git a/services/local-service/internal/urlutil/context_url.go b/services/local-service/internal/urlutil/context_url.go index 417297577..f1abf5cf8 100644 --- a/services/local-service/internal/urlutil/context_url.go +++ b/services/local-service/internal/urlutil/context_url.go @@ -14,7 +14,9 @@ func SanitizeContextURL(raw string) string { } parsed, err := url.Parse(trimmed) if err != nil { - return trimmed + // 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 "" } parsed.User = nil parsed.RawQuery = "" diff --git a/services/local-service/internal/urlutil/context_url_test.go b/services/local-service/internal/urlutil/context_url_test.go index f1ca3b3d4..edfc0192a 100644 --- a/services/local-service/internal/urlutil/context_url_test.go +++ b/services/local-service/internal/urlutil/context_url_test.go @@ -15,3 +15,10 @@ func TestSanitizeContextURLKeepsLocalSchemesStable(t *testing.T) { t.Fatalf("expected local scheme to drop query and fragment, 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) + } +} From a3aeadda9496886ba23cd754d15128248116780b Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 11 May 2026 03:10:37 +0000 Subject: [PATCH 36/41] fix(rpc): keep typed dto responses on transport Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/rpc/dispatch_core_test.go | 47 ++++++------ .../internal/rpc/dispatch_storage_test.go | 26 ++++--- .../local-service/internal/rpc/jsonrpc.go | 11 +-- .../internal/rpc/jsonrpc_test.go | 19 +++++ .../internal/rpc/notifications.go | 74 +++++++++++++++---- .../internal/rpc/notifications_test.go | 14 ++++ .../internal/rpc/rpc_test_helpers_test.go | 34 +++++++++ .../rpc/server_stream_notifications_test.go | 4 +- 8 files changed, 169 insertions(+), 60 deletions(-) diff --git a/services/local-service/internal/rpc/dispatch_core_test.go b/services/local-service/internal/rpc/dispatch_core_test.go index b5a16742f..89d1a08b7 100644 --- a/services/local-service/internal/rpc/dispatch_core_test.go +++ b/services/local-service/internal/rpc/dispatch_core_test.go @@ -41,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) } @@ -82,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) @@ -136,7 +136,7 @@ func TestDispatchTaskDetailGetIncludesActiveApprovalAnchor(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) 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"]) @@ -189,7 +189,7 @@ func TestDispatchTaskDetailGetOmitsApprovalAnchorForCompletedTask(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["approval_request"] != nil { t.Fatalf("expected approval_request to be nil, got %+v", data["approval_request"]) } @@ -314,8 +314,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)) } @@ -360,7 +360,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) } @@ -399,7 +399,7 @@ func TestDispatchTaskToolCallsListReturnsPersistedToolCalls(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]["tool_name"] != "read_file" { t.Fatalf("expected rpc task tool calls list to return read_file, got %+v", items) } @@ -441,8 +441,9 @@ func TestDispatchTaskSteerReturnsUpdatedTask(t *testing.T) { 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) } } @@ -460,7 +461,7 @@ func TestDispatchReturnsSettingsGet(t *testing.T) { 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) } @@ -486,15 +487,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) } } @@ -517,7 +519,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) } @@ -535,8 +537,8 @@ func TestDispatchReturnsPluginRuntimeList(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) == 0 { t.Fatalf("expected plugin runtime query to return declared runtimes, got %+v", data) } @@ -544,7 +546,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) } @@ -562,9 +564,10 @@ func TestDispatchReturnsPluginList(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) + 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) } } @@ -580,7 +583,7 @@ func TestDispatchReturnsPluginDetail(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["plugin"].(map[string]any)["plugin_id"] != "ocr" { t.Fatalf("expected plugin detail query to resolve ocr plugin, got %+v", data) } @@ -588,7 +591,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) } diff --git a/services/local-service/internal/rpc/dispatch_storage_test.go b/services/local-service/internal/rpc/dispatch_storage_test.go index f653a7b00..b8d91715b 100644 --- a/services/local-service/internal/rpc/dispatch_storage_test.go +++ b/services/local-service/internal/rpc/dispatch_storage_test.go @@ -45,7 +45,7 @@ func TestDispatchReturnsSecurityAuditList(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]["audit_id"] != "audit_001" { t.Fatalf("expected stored audit_001, got %+v", items) } @@ -77,7 +77,7 @@ func TestDispatchReturnsSecurityRestorePointsList(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]["recovery_point_id"] != "rp_001" { t.Fatalf("expected stored rp_001, got %+v", items) } @@ -122,7 +122,7 @@ func TestDispatchReturnsSecurityRestoreApplyResult(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 _, 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) } @@ -140,12 +140,12 @@ func TestDispatchReturnsNotepadUpdateResult(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) 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) } @@ -179,7 +179,7 @@ func TestDispatchReturnsTaskArtifactList(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]["artifact_id"] != "art_rpc_001" { t.Fatalf("expected artifact list item, got %+v", items) } @@ -213,7 +213,7 @@ func TestDispatchReturnsTaskArtifactOpen(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["open_action"] != "open_file" || data["artifact"].(map[string]any)["artifact_id"] != "art_rpc_open_001" { t.Fatalf("expected opened artifact, got %+v", data) } @@ -247,8 +247,9 @@ func TestDispatchReturnsDeliveryOpenForArtifact(t *testing.T) { 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) } } @@ -283,8 +284,9 @@ func TestDispatchReturnsDeliveryOpenForTaskResult(t *testing.T) { 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) } } @@ -339,7 +341,7 @@ func TestDispatchReturnsDeliveryOpenForResultPage(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["open_action"] != "result_page" { t.Fatalf("expected result_page action, got %+v", data) } diff --git a/services/local-service/internal/rpc/jsonrpc.go b/services/local-service/internal/rpc/jsonrpc.go index e83a578ad..2226484b7 100644 --- a/services/local-service/internal/rpc/jsonrpc.go +++ b/services/local-service/internal/rpc/jsonrpc.go @@ -117,7 +117,9 @@ func newSuccessEnvelope(id json.RawMessage, data any, serverTime string) success JSONRPC: "2.0", ID: normalizeID(id), Result: resultEnvelope{ - Data: protocolResultData(data), + // 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, }, @@ -126,13 +128,6 @@ func newSuccessEnvelope(id json.RawMessage, data any, serverTime string) success } } -func protocolResultData(data any) any { - if mappable, ok := data.(interface{ Map() map[string]any }); ok { - return mappable.Map() - } - return data -} - // newErrorEnvelope assembles the shared error response format. func newErrorEnvelope(id json.RawMessage, rpcErr *rpcError) errorEnvelope { var envelope errorEnvelope 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/notifications.go b/services/local-service/internal/rpc/notifications.go index 61c8c0ca4..776b90c14 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,25 +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 interface{ Map() map[string]any }: - collectTaskIDs(value.Map(), ids) - 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 2b48baac6..2bbb03a6c 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 TestRequestRoutingHintsExtractsTaskSessionAndTrace(t *testing.T) { request := requestEnvelope{ JSONRPC: "2.0", 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 51d073455..0be25fc8b 100644 --- a/services/local-service/internal/rpc/rpc_test_helpers_test.go +++ b/services/local-service/internal/rpc/rpc_test_helpers_test.go @@ -231,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_notifications_test.go b/services/local-service/internal/rpc/server_stream_notifications_test.go index 206502484..abd0e9296 100644 --- a/services/local-service/internal/rpc/server_stream_notifications_test.go +++ b/services/local-service/internal/rpc/server_stream_notifications_test.go @@ -66,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") } @@ -144,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) } From b0260bed4e5f1b3a0502510ed18277555f5fd7f8 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 11 May 2026 03:42:50 +0000 Subject: [PATCH 37/41] fix(local-service): sanitize nested opaque context urls Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../internal/urlutil/context_url.go | 27 ++++++++++++++++++- .../internal/urlutil/context_url_test.go | 21 +++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/services/local-service/internal/urlutil/context_url.go b/services/local-service/internal/urlutil/context_url.go index f1abf5cf8..1ca8ceefa 100644 --- a/services/local-service/internal/urlutil/context_url.go +++ b/services/local-service/internal/urlutil/context_url.go @@ -18,8 +18,33 @@ func SanitizeContextURL(raw string) string { // 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 = "" - return parsed.String() + 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 index edfc0192a..b4364726e 100644 --- a/services/local-service/internal/urlutil/context_url_test.go +++ b/services/local-service/internal/urlutil/context_url_test.go @@ -16,6 +16,27 @@ func TestSanitizeContextURLKeepsLocalSchemesStable(t *testing.T) { } } +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 != "" { From 2edaf8c7f0c63ed114dd5adce426c6d2bd789f74 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 11 May 2026 04:07:11 +0000 Subject: [PATCH 38/41] fix(local-service): tighten typed entry dto validation Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../local-service/internal/rpc/entry_dto.go | 242 ++++++++++++++++-- 1 file changed, 220 insertions(+), 22 deletions(-) diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index 313d35863..63c7862f2 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -80,19 +80,23 @@ type AgentTaskStartParams = orchestrator.StartTaskRequest type AgentTaskDetailGetParams = orchestrator.TaskDetailGetRequest func decodeAgentInputSubmitParams(raw json.RawMessage) (map[string]any, *rpcError) { - return decodeTypedProtocolParams(raw, validateAgentInputSubmitParams, decodeTypedProtocolDTO[AgentInputSubmitParams], func(request AgentInputSubmitParams) map[string]any { + return decodeTypedProtocolParams(raw, validateAgentInputSubmitParams, orchestrator.SubmitInputRequestFromParams, func(request AgentInputSubmitParams) map[string]any { return request.ProtocolParamsMap() }) } func decodeAgentTaskStartParams(raw json.RawMessage) (map[string]any, *rpcError) { - return decodeTypedProtocolParams(raw, validateAgentTaskStartParams, decodeTypedProtocolDTO[AgentTaskStartParams], func(request AgentTaskStartParams) map[string]any { + 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() }) } func decodeAgentTaskDetailGetParams(raw json.RawMessage) (map[string]any, *rpcError) { - return decodeTypedProtocolParams(raw, validateAgentTaskDetailGetParams, decodeTypedProtocolDTO[AgentTaskDetailGetParams], func(request AgentTaskDetailGetParams) map[string]any { + return decodeTypedProtocolParams(raw, validateAgentTaskDetailGetParams, orchestrator.TaskDetailGetRequestFromParams, func(request AgentTaskDetailGetParams) map[string]any { return request.ProtocolParamsMap() }) } @@ -101,7 +105,7 @@ func decodeAgentTaskListParams(raw json.RawMessage) (map[string]any, *rpcError) return decodeParamsWithValidation(raw, validateAgentTaskListParams) } -func decodeTypedProtocolParams[T any](raw json.RawMessage, validate func(map[string]any) *rpcError, decode func([]byte) (T, error), normalize func(T) map[string]any) (map[string]any, *rpcError) { +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("{}") @@ -120,22 +124,13 @@ func decodeTypedProtocolParams[T any](raw json.RawMessage, validate func(map[str return nil, err } } - if decode != nil && normalize != nil { - typedPayload, err := decode(trimmed) - if err != nil { - return nil, invalidParamsError("params do not match the registered method dto") - } + if fromParams != nil && normalize != nil { + typedPayload := fromParams(payload) return normalize(typedPayload), nil } return map[string]any{}, nil } -func decodeTypedProtocolDTO[T any](raw []byte) (T, error) { - var dto T - err := json.Unmarshal(raw, &dto) - return dto, err -} - func decodeParamsRequiringRequestMeta(raw json.RawMessage) (map[string]any, *rpcError) { return decodeParamsWithValidation(raw, requireRequestMeta) } @@ -182,10 +177,19 @@ func validateAgentInputSubmitParams(params map[string]any) *rpcError { if err := validateContextEnvelope(params); err != nil { return err } - options := mapObject(params, "options") + 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 } @@ -212,13 +216,23 @@ func validateAgentTaskStartParams(params map[string]any) *rpcError { if err := validateContextEnvelope(params); err != nil { return err } - delivery := mapObject(params, "delivery") + 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 } @@ -288,16 +302,106 @@ func validateTaskStartInputPayload(input, context map[string]any) *rpcError { } func validateContextEnvelope(params map[string]any) *rpcError { - pageContext := mapObject(mapObject(params, "input"), "page_context") - if err := optionalEnumValue(pageContext, "browser_kind", browserKindSet); err != nil { + 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 } - context := mapObject(params, "context") if len(context) == 0 { return nil } - page := mapObject(context, "page") - if err := optionalEnumValue(page, "browser_kind", browserKindSet); err != 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 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 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 := optionalEnumValue(values, "browser_kind", browserKindSet); err != nil { + return err + } + if err := validateIntegerFields(values, "process_id"); err != nil { return err } return nil @@ -341,6 +445,18 @@ func mapObject(values map[string]any, key string) 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 { @@ -365,6 +481,88 @@ func requireInteger(values map[string]any, key string) *rpcError { 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) + } + } + 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 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 { From d5428f479ce8a7e1088e65549b35df0414dfb25d Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 11 May 2026 07:30:50 +0000 Subject: [PATCH 39/41] fix(rpc): require plugin request metadata Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- packages/protocol/rpc/methods.ts | 6 +-- .../internal/rpc/dispatch_core_test.go | 50 +++++++++++++++++-- .../local-service/internal/rpc/methods.go | 6 +-- .../internal/rpc/protocol_registry_test.go | 4 +- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/packages/protocol/rpc/methods.ts b/packages/protocol/rpc/methods.ts index 8de16178c..c5ab8d3d4 100644 --- a/packages/protocol/rpc/methods.ts +++ b/packages/protocol/rpc/methods.ts @@ -859,7 +859,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. @@ -870,7 +870,7 @@ export interface AgentPluginRuntimeListResult { } export interface AgentPluginListParams { - request_meta?: RequestMeta; + request_meta: RequestMeta; page?: { limit: number; offset: number; @@ -886,7 +886,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/rpc/dispatch_core_test.go b/services/local-service/internal/rpc/dispatch_core_test.go index 89d1a08b7..925c980e9 100644 --- a/services/local-service/internal/rpc/dispatch_core_test.go +++ b/services/local-service/internal/rpc/dispatch_core_test.go @@ -531,7 +531,7 @@ 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 { @@ -558,7 +558,7 @@ 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 { @@ -577,7 +577,7 @@ 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 { @@ -607,3 +607,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/methods.go b/services/local-service/internal/rpc/methods.go index e9014e719..5882e217b 100644 --- a/services/local-service/internal/rpc/methods.go +++ b/services/local-service/internal/rpc/methods.go @@ -87,9 +87,9 @@ func (s *Server) stableMethodRegistry() []registeredMethod { registered(methodAgentSettingsGet, decodeParamsRequiringRequestMeta, s.handleAgentSettingsGet), registered(methodAgentSettingsUpdate, decodeParamsRequiringRequestMeta, s.handleAgentSettingsUpdate), registered(methodAgentSettingsModelValidate, decodeParamsRequiringRequestMeta, s.handleAgentSettingsModelValidate), - registered(methodAgentPluginRuntimeList, decodeParams, s.handleAgentPluginRuntimeList), - registered(methodAgentPluginList, decodeParams, s.handleAgentPluginList), - registered(methodAgentPluginDetailGet, decodeParams, s.handleAgentPluginDetailGet), + registered(methodAgentPluginRuntimeList, decodeParamsRequiringRequestMeta, s.handleAgentPluginRuntimeList), + registered(methodAgentPluginList, decodeParamsRequiringRequestMeta, s.handleAgentPluginList), + registered(methodAgentPluginDetailGet, decodeParamsRequiringRequestMeta, s.handleAgentPluginDetailGet), } } diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index d48f92966..0337d46cd 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -98,7 +98,9 @@ func TestStableMethodRegistryDispatchMatrix(t *testing.T) { methodAgentTaskInspectorRun: decodeParamsRequiringRequestMeta, methodAgentDeliveryOpen: decodeParamsRequiringRequestMeta, methodAgentSettingsGet: decodeParamsRequiringRequestMeta, - methodAgentPluginDetailGet: decodeParams, + methodAgentPluginRuntimeList: decodeParamsRequiringRequestMeta, + methodAgentPluginList: decodeParamsRequiringRequestMeta, + methodAgentPluginDetailGet: decodeParamsRequiringRequestMeta, } for _, method := range server.stableMethodRegistry() { From 491c72f68a297f267e933755c90ce50366df6e25 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 11 May 2026 08:22:02 +0000 Subject: [PATCH 40/41] fix(local-service): tighten typed session and intent contracts Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../orchestrator/protocol_dto_test.go | 29 +++++++++ .../orchestrator/protocol_response_dto.go | 2 +- .../internal/orchestrator/screen_analyze.go | 7 ++- .../internal/orchestrator/task_query.go | 3 + .../local-service/internal/rpc/entry_dto.go | 6 ++ .../internal/rpc/protocol_registry_test.go | 62 +++++++++++++++++++ 6 files changed, 106 insertions(+), 3 deletions(-) diff --git a/services/local-service/internal/orchestrator/protocol_dto_test.go b/services/local-service/internal/orchestrator/protocol_dto_test.go index 227f14e71..218b83cb9 100644 --- a/services/local-service/internal/orchestrator/protocol_dto_test.go +++ b/services/local-service/internal/orchestrator/protocol_dto_test.go @@ -174,3 +174,32 @@ func TestTaskEntryResponseRejectsMissingRequiredDeclaredFields(t *testing.T) { }) } } + +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_response_dto.go b/services/local-service/internal/orchestrator/protocol_response_dto.go index 9c0d974b7..f242840a4 100644 --- a/services/local-service/internal/orchestrator/protocol_response_dto.go +++ b/services/local-service/internal/orchestrator/protocol_response_dto.go @@ -427,7 +427,7 @@ func intentPayloadPointerFromMap(values map[string]any, key string) (*IntentPayl return nil, fmt.Errorf("%s: %w", key, err) } if !ok { - arguments = nil + arguments = map[string]any{} } else { arguments = cloneProtocolMap(arguments) } diff --git a/services/local-service/internal/orchestrator/screen_analyze.go b/services/local-service/internal/orchestrator/screen_analyze.go index 343e3dc74..ffc5638eb 100644 --- a/services/local-service/internal/orchestrator/screen_analyze.go +++ b/services/local-service/internal/orchestrator/screen_analyze.go @@ -16,7 +16,7 @@ import ( type screenIntentDTO struct { Name string `json:"name"` - Arguments map[string]any `json:"arguments,omitempty"` + Arguments map[string]any `json:"arguments"` } type emptyIntentArguments struct{} @@ -346,7 +346,10 @@ func (s *Service) executeScreenAnalysisAfterApproval(task runengine.TaskRecord, } func protocolIntentMap(name string, arguments any) map[string]any { - intent := screenIntentDTO{Name: name} + intent := screenIntentDTO{ + Name: name, + Arguments: map[string]any{}, + } if arguments != nil { intent.Arguments = protocolMapFromDTO(arguments) } diff --git a/services/local-service/internal/orchestrator/task_query.go b/services/local-service/internal/orchestrator/task_query.go index 18e26149f..220aa3964 100644 --- a/services/local-service/internal/orchestrator/task_query.go +++ b/services/local-service/internal/orchestrator/task_query.go @@ -226,6 +226,9 @@ func intentPayloadFromTaskIntent(intent map[string]any) *IntentPayload { } 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 } diff --git a/services/local-service/internal/rpc/entry_dto.go b/services/local-service/internal/rpc/entry_dto.go index 63c7862f2..0df70fc89 100644 --- a/services/local-service/internal/rpc/entry_dto.go +++ b/services/local-service/internal/rpc/entry_dto.go @@ -152,6 +152,9 @@ 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 @@ -197,6 +200,9 @@ 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 diff --git a/services/local-service/internal/rpc/protocol_registry_test.go b/services/local-service/internal/rpc/protocol_registry_test.go index 0337d46cd..915c650d0 100644 --- a/services/local-service/internal/rpc/protocol_registry_test.go +++ b/services/local-service/internal/rpc/protocol_registry_test.go @@ -292,6 +292,68 @@ func TestStableDTOsRejectWhitespaceOnlyRequiredStrings(t *testing.T) { } } +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", From 3c19971ff3f09f0b45d0889b710bb4c3b9233cc0 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 11 May 2026 13:40:23 +0000 Subject: [PATCH 41/41] fix(rpc): resolve notifications test merge conflict Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Blackcloudss <161042579+Blackcloudss@users.noreply.github.com> --- .../local-service/internal/rpc/notifications.go | 2 +- .../internal/rpc/notifications_test.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/services/local-service/internal/rpc/notifications.go b/services/local-service/internal/rpc/notifications.go index 776b90c14..0eec351e8 100644 --- a/services/local-service/internal/rpc/notifications.go +++ b/services/local-service/internal/rpc/notifications.go @@ -45,7 +45,7 @@ func requestRoutingHints(request requestEnvelope) (map[string]bool, string, stri } func shouldTrackStartedTask(method string) bool { - return method == methodAgentTaskStart || method == methodAgentInputSubmit + return method == methodAgentTaskStart || method == methodAgentInputSubmit || method == methodAgentNotepadConvertToTask } // shouldClaimResponseTaskOwnership scopes late response-based task ownership to diff --git a/services/local-service/internal/rpc/notifications_test.go b/services/local-service/internal/rpc/notifications_test.go index 2bbb03a6c..e742d6f4b 100644 --- a/services/local-service/internal/rpc/notifications_test.go +++ b/services/local-service/internal/rpc/notifications_test.go @@ -69,6 +69,20 @@ func TestOwnedTaskIDsForReplayClaimsTypedTaskStartResponseTaskIDs(t *testing.T) } } +func TestOwnedTaskIDsForReplayClaimsResponseTaskIDsForNotepadConvert(t *testing.T) { + response := newSuccessEnvelope(json.RawMessage(`"req-notepad-convert"`), map[string]any{ + "task": map[string]any{ + "task_id": "task_notepad_started", + }, + }, "2026-04-08T10:00:00Z") + + taskIDs := ownedTaskIDsForReplay("agent.notepad.convert_to_task", nil, response) + expected := []string{"task_notepad_started"} + if !reflect.DeepEqual(taskIDs, expected) { + t.Fatalf("expected notepad.convert_to_task to claim response task ids %v, got %v", expected, taskIDs) + } +} + func TestRequestRoutingHintsExtractsTaskSessionAndTrace(t *testing.T) { request := requestEnvelope{ JSONRPC: "2.0",