From 5d69183866a1da58adcbf0f583f85355946720b9 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 21 May 2026 03:50:51 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(gateway):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E5=AE=A1=E6=89=B9=20RPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关联 issue: #688 新增 gateway.approvePlan 协议、Gateway 分发与 Runtime bridge 适配,Web 端在最新 draft 计划上提供批准并执行入口。 --- docs/gateway-rpc-api.md | 35 +++ docs/reference/gateway-rpc-api.md | 41 +++- internal/cli/gateway_runtime_bridge.go | 31 +++ internal/cli/gateway_runtime_bridge_test.go | 85 +++++++ internal/gateway/bootstrap.go | 60 +++++ internal/gateway/bootstrap_test.go | 121 ++++++++++ internal/gateway/contracts.go | 28 +++ internal/gateway/metrics.go | 1 + internal/gateway/multi_workspace_runtime.go | 13 + .../gateway/multi_workspace_runtime_test.go | 41 ++++ internal/gateway/protocol/jsonrpc.go | 36 +++ internal/gateway/protocol/jsonrpc_test.go | 49 ++++ internal/gateway/registry.go | 1 + internal/gateway/rpc_dispatch.go | 1 + internal/gateway/rpc_dispatch_test.go | 103 ++++++++ internal/gateway/runtime_errors.go | 2 + internal/gateway/security.go | 1 + internal/gateway/security_test.go | 5 + internal/gateway/types.go | 2 + internal/gateway/validate.go | 39 +++ internal/runtime/plan_approval.go | 19 ++ internal/runtime/planning.go | 8 +- internal/runtime/planning_test.go | 19 +- web/src/api/gateway.test.ts | 4 +- web/src/api/gateway.ts | 6 + web/src/api/protocol.ts | 10 + web/src/components/chat/ChatPanel.test.tsx | 175 +++++++++++++- web/src/components/chat/ChatPanel.tsx | 223 +++++++++++++++++- 28 files changed, 1142 insertions(+), 17 deletions(-) diff --git a/docs/gateway-rpc-api.md b/docs/gateway-rpc-api.md index 876534147..6425bd9ed 100644 --- a/docs/gateway-rpc-api.md +++ b/docs/gateway-rpc-api.md @@ -421,6 +421,41 @@ type ResolvePermissionParams struct { --- +## Method: gateway.approvePlan + +- Stability: Stable +- Auth Required: Yes +- Request Schema: + +```go +type ApprovePlanParams struct { + SessionID string `json:"session_id"` // MUST + PlanID string `json:"plan_id"` // MUST + Revision int `json:"revision"` // MUST > 0 +} +``` + +- Response Schema: + +```json +{ + "type": "ack", + "action": "approve_plan", + "session_id": "session-1", + "payload": { + "plan_id": "plan-1", + "revision": 2, + "status": "approved" + } +} +``` + +- Semantics: + - 仅批准当前会话中匹配 `plan_id + revision` 的 `draft` 计划。 + - 成功后客户端可再调用 `gateway.run({ "mode": "build" })` 执行已批准计划。 + +--- + ## Method: gateway.userQuestionAnswer - Stability: Beta diff --git a/docs/reference/gateway-rpc-api.md b/docs/reference/gateway-rpc-api.md index 4ec01eec0..0a9c8be45 100644 --- a/docs/reference/gateway-rpc-api.md +++ b/docs/reference/gateway-rpc-api.md @@ -778,7 +778,44 @@ Observation: --- -## 15. wake.openUrl +## 15. gateway.approvePlan + +Method: `gateway.approvePlan` +Stability: `Stable` +Auth Required: `Yes` + +Request Schema: + +```go +type ApprovePlanParams struct { + SessionID string `json:"session_id"` // MUST + PlanID string `json:"plan_id"` // MUST + Revision int `json:"revision"` // MUST > 0 +} +``` + +Response Schema: + +```json +{ + "type": "ack", + "action": "approve_plan", + "session_id": "session-1", + "payload": { + "plan_id": "plan-1", + "revision": 2, + "status": "approved" + } +} +``` + +Semantics: +1. Only the current session plan matching `plan_id + revision` and `draft` status can be approved. +2. After success, clients can call `gateway.run` with `mode: "build"` to execute the approved plan. + +--- + +## 16. wake.openUrl Method: `wake.openUrl` Stability: `Experimental` @@ -828,7 +865,7 @@ Observation: --- -## 16. gateway.event(服务端通知) +## 17. gateway.event(服务端通知) Method: `gateway.event` Stability: `Stable` diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index 304c25d08..d51195d25 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -497,6 +497,37 @@ func (b *gatewayRuntimePortBridge) ResolvePermission(ctx context.Context, input }) } +// ApprovePlan 将网关计划批准请求转换为 runtime 当前计划批准输入。 +func (b *gatewayRuntimePortBridge) ApprovePlan( + ctx context.Context, + input gateway.ApprovePlanInput, +) (gateway.ApprovePlanResult, error) { + if err := b.ensureRuntimeAccess(input.SubjectID); err != nil { + return gateway.ApprovePlanResult{}, err + } + approver, ok := b.runtime.(agentruntime.PlanApprover) + if !ok { + return gateway.ApprovePlanResult{}, fmt.Errorf("gateway runtime bridge: runtime does not support plan approval") + } + sessionID := strings.TrimSpace(input.SessionID) + planID := strings.TrimSpace(input.PlanID) + if err := approver.ApproveCurrentPlan(ctx, agentruntime.ApproveCurrentPlanInput{ + SessionID: sessionID, + PlanID: planID, + Revision: input.Revision, + }); err != nil { + if agentruntime.IsPlanApprovalInvalidError(err) { + return gateway.ApprovePlanResult{}, fmt.Errorf("%w: %v", gateway.ErrRuntimeInvalidAction, err) + } + return gateway.ApprovePlanResult{}, err + } + return gateway.ApprovePlanResult{ + PlanID: planID, + Revision: input.Revision, + Status: "approved", + }, nil +} + // ResolveUserQuestion 将网关 ask_user 回答转发到 runtime。 func (b *gatewayRuntimePortBridge) ResolveUserQuestion(ctx context.Context, input gateway.UserQuestionAnswerInput) error { if err := b.ensureRuntimeAccess(input.SubjectID); err != nil { diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index dc52148cb..a62f8d860 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -89,6 +89,12 @@ type runtimeStub struct { checkpointDiffErr error } +type runtimePlanApproverStub struct { + *runtimeStub + approveInput agentruntime.ApproveCurrentPlanInput + approveErr error +} + const testBridgeSubjectID = bridgeLocalSubjectID func (s *runtimeStub) Submit(_ context.Context, input agentruntime.PrepareInput) error { @@ -132,6 +138,14 @@ func (s *runtimeStub) ResolvePermission(_ context.Context, input agentruntime.Pe return s.permissionErr } +func (s *runtimePlanApproverStub) ApproveCurrentPlan( + _ context.Context, + input agentruntime.ApproveCurrentPlanInput, +) error { + s.approveInput = input + return s.approveErr +} + func (s *runtimeStub) ResolveUserQuestion(_ context.Context, input agentruntime.UserQuestionResolutionInput) error { s.userQuestionInput = input return s.userQuestionErr @@ -1075,6 +1089,77 @@ func TestGatewayRuntimePortBridgeListSessionTodosAndSnapshot(t *testing.T) { }) } +func TestGatewayRuntimePortBridgeApprovePlan(t *testing.T) { + runtimeSvc := &runtimePlanApproverStub{ + runtimeStub: &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, + } + bridge, err := newGatewayRuntimePortBridge(context.Background(), runtimeSvc, testSessionStore) + if err != nil { + t.Fatalf("new bridge: %v", err) + } + t.Cleanup(func() { _ = bridge.Close() }) + + result, err := bridge.ApprovePlan(context.Background(), gateway.ApprovePlanInput{ + SubjectID: testBridgeSubjectID, + SessionID: " session-1 ", + PlanID: " plan-1 ", + Revision: 3, + }) + if err != nil { + t.Fatalf("approve_plan: %v", err) + } + if runtimeSvc.approveInput.SessionID != "session-1" || runtimeSvc.approveInput.PlanID != "plan-1" || runtimeSvc.approveInput.Revision != 3 { + t.Fatalf("approve input = %#v, want trimmed session/plan revision", runtimeSvc.approveInput) + } + if result.PlanID != "plan-1" || result.Revision != 3 || result.Status != "approved" { + t.Fatalf("approve result = %#v, want approved plan-1 revision 3", result) + } +} + +func TestGatewayRuntimePortBridgeApprovePlanUnsupportedRuntime(t *testing.T) { + bridge, err := newGatewayRuntimePortBridge( + context.Background(), + &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, + testSessionStore, + ) + if err != nil { + t.Fatalf("new bridge: %v", err) + } + t.Cleanup(func() { _ = bridge.Close() }) + + _, err = bridge.ApprovePlan(context.Background(), gateway.ApprovePlanInput{ + SubjectID: testBridgeSubjectID, + SessionID: "session-1", + PlanID: "plan-1", + Revision: 1, + }) + if err == nil || !strings.Contains(err.Error(), "runtime does not support plan approval") { + t.Fatalf("approve_plan unsupported error = %v", err) + } +} + +func TestGatewayRuntimePortBridgeApprovePlanInvalidAction(t *testing.T) { + runtimeSvc := &runtimePlanApproverStub{ + runtimeStub: &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, + approveErr: agentruntime.ErrPlanApprovalRevisionMismatch, + } + bridge, err := newGatewayRuntimePortBridge(context.Background(), runtimeSvc, testSessionStore) + if err != nil { + t.Fatalf("new bridge: %v", err) + } + t.Cleanup(func() { _ = bridge.Close() }) + + _, err = bridge.ApprovePlan(context.Background(), gateway.ApprovePlanInput{ + SubjectID: testBridgeSubjectID, + SessionID: "session-1", + PlanID: "plan-1", + Revision: 1, + }) + if !errors.Is(err, gateway.ErrRuntimeInvalidAction) { + t.Fatalf("approve_plan error = %v, want ErrRuntimeInvalidAction", err) + } +} + func TestGatewayRuntimePortBridgeLoadSessionNotFoundBranches(t *testing.T) { t.Parallel() diff --git a/internal/gateway/bootstrap.go b/internal/gateway/bootstrap.go index 40b378b02..08f01ea83 100644 --- a/internal/gateway/bootstrap.go +++ b/internal/gateway/bootstrap.go @@ -1683,11 +1683,68 @@ func handleResolvePermissionFrame(ctx context.Context, frame MessageFrame, runti } } +// handleApprovePlanFrame 处理计划批准请求,并把能力收敛到可选 runtime 端口。 +func handleApprovePlanFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { + if runtimePort == nil { + return runtimePortUnavailableFrame(frame) + } + subjectID, subjectErr := requireAuthenticatedSubjectID(ctx) + if subjectErr != nil { + return errorFrame(frame, subjectErr) + } + approvalPort, approvalErr := requirePlanApprovalRuntimePort(runtimePort) + if approvalErr != nil { + return errorFrame(frame, approvalErr) + } + + input, err := decodeApprovePlanPayload(frame.Payload) + if err != nil { + return errorFrame(frame, err) + } + input.SubjectID = subjectID + if input.SessionID == "" { + input.SessionID = strings.TrimSpace(frame.SessionID) + } + if input.SessionID == "" { + return errorFrame(frame, NewMissingRequiredFieldError("payload.session_id")) + } + if input.PlanID == "" { + return errorFrame(frame, NewMissingRequiredFieldError("payload.plan_id")) + } + if input.Revision <= 0 { + return errorFrame(frame, NewFrameError(ErrorCodeInvalidAction, "invalid approve_plan revision")) + } + + callCtx, cancel := withRuntimeOperationTimeout(ctx) + defer cancel() + result, approveErr := approvalPort.ApprovePlan(callCtx, input) + if approveErr != nil { + return runtimeCallFailedFrame(callCtx, frame, approveErr, "approve_plan") + } + + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionApprovePlan, + RequestID: frame.RequestID, + SessionID: input.SessionID, + Payload: result, + } +} + // runtimePortUnavailableFrame 在 runtime 未注入时返回统一错误。 func runtimePortUnavailableFrame(frame MessageFrame) MessageFrame { return errorFrame(frame, NewFrameError(ErrorCodeInternalError, "runtime port is unavailable")) } +// requirePlanApprovalRuntimePort 校验当前 runtime 端口是否支持计划批准能力。 +func requirePlanApprovalRuntimePort(runtimePort RuntimePort) (PlanApprovalRuntimePort, *FrameError) { + approvalPort, ok := runtimePort.(PlanApprovalRuntimePort) + if !ok { + return nil, NewFrameError(ErrorCodeInternalError, "plan approval runtime port is unavailable") + } + return approvalPort, nil +} + // requireManagementRuntimePort 校验当前 runtime 端口是否支持管理面扩展能力。 func requireManagementRuntimePort(runtimePort RuntimePort) (ManagementRuntimePort, *FrameError) { managementPort, ok := runtimePort.(ManagementRuntimePort) @@ -1785,6 +1842,9 @@ func runtimeCallFailedFrame(ctx context.Context, frame MessageFrame, err error, case errors.Is(err, ErrRuntimeResourceNotFound): errorCode = ErrorCodeResourceNotFound message = fmt.Sprintf("%s target not found", normalizedOperation) + case errors.Is(err, ErrRuntimeInvalidAction): + errorCode = ErrorCodeInvalidAction + message = fmt.Sprintf("%s invalid action", normalizedOperation) case errors.Is(err, context.DeadlineExceeded): errorCode = ErrorCodeTimeout message = fmt.Sprintf("%s timed out", normalizedOperation) diff --git a/internal/gateway/bootstrap_test.go b/internal/gateway/bootstrap_test.go index 441372589..754fb2d90 100644 --- a/internal/gateway/bootstrap_test.go +++ b/internal/gateway/bootstrap_test.go @@ -29,6 +29,7 @@ type bootstrapRuntimeStub struct { listSessionSkillsFn func(ctx context.Context, input ListSessionSkillsInput) ([]SessionSkillState, error) listAvailableFn func(ctx context.Context, input ListAvailableSkillsInput) ([]AvailableSkillState, error) resolvePermissionFn func(ctx context.Context, input PermissionResolutionInput) error + approvePlanFn func(ctx context.Context, input ApprovePlanInput) (ApprovePlanResult, error) cancelRunFn func(ctx context.Context, input CancelInput) (bool, error) events <-chan RuntimeEvent listSessionsFn func(ctx context.Context) ([]SessionSummary, error) @@ -140,6 +141,13 @@ func (s *bootstrapRuntimeStub) ResolvePermission(ctx context.Context, input Perm return nil } +func (s *bootstrapRuntimeStub) ApprovePlan(ctx context.Context, input ApprovePlanInput) (ApprovePlanResult, error) { + if s != nil && s.approvePlanFn != nil { + return s.approvePlanFn(ctx, input) + } + return ApprovePlanResult{}, nil +} + func (s *bootstrapRuntimeStub) ResolveUserQuestion(ctx context.Context, input UserQuestionAnswerInput) error { return nil } @@ -2088,6 +2096,14 @@ func TestRuntimeCallFailedFrameSanitizesErrorAndMapsCode(t *testing.T) { if canceledErr.Error.Message != "run canceled" { t.Fatalf("canceled message = %q, want %q", canceledErr.Error.Message, "run canceled") } + + invalidActionErr := runtimeCallFailedFrame(context.Background(), frame, ErrRuntimeInvalidAction, "approve_plan") + if invalidActionErr.Error == nil || invalidActionErr.Error.Code != ErrorCodeInvalidAction.String() { + t.Fatalf("invalid action payload = %#v, want invalid_action", invalidActionErr.Error) + } + if invalidActionErr.Error.Message != "approve_plan invalid action" { + t.Fatalf("invalid action message = %q, want %q", invalidActionErr.Error.Message, "approve_plan invalid action") + } } func TestNormalizeRunID(t *testing.T) { @@ -2594,6 +2610,88 @@ func TestHandleCancelListLoadResolveBranches(t *testing.T) { t.Fatalf("response message = %q, want %q", response.Error.Message, "resolve_permission failed") } }) + + t.Run("approve plan invalid payload", func(t *testing.T) { + response := handleApprovePlanFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionApprovePlan, + RequestID: "approve-invalid", + Payload: map[string]any{ + "session_id": "session-1", + "plan_id": "", + "revision": 1, + }, + }, &bootstrapRuntimeStub{}) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeMissingRequiredField.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeMissingRequiredField.String()) + } + }) + + t.Run("approve plan success", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + approvePlanFn: func(ctx context.Context, input ApprovePlanInput) (ApprovePlanResult, error) { + if _, ok := ctx.Deadline(); !ok { + t.Fatal("approve plan should use timeout context") + } + if input.SubjectID == "" { + t.Fatal("subject id should be populated") + } + if input.SessionID != "session-1" || input.PlanID != "plan-1" || input.Revision != 2 { + t.Fatalf("approve input = %#v", input) + } + return ApprovePlanResult{PlanID: input.PlanID, Revision: input.Revision, Status: "approved"}, nil + }, + } + response := handleApprovePlanFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionApprovePlan, + RequestID: "approve-ok", + Payload: map[string]any{ + "session_id": "session-1", + "plan_id": "plan-1", + "revision": 2, + }, + }, stub) + if response.Type != FrameTypeAck || response.Action != FrameActionApprovePlan { + t.Fatalf("response = %#v, want approve_plan ack", response) + } + payload, ok := response.Payload.(ApprovePlanResult) + if !ok { + t.Fatalf("payload type = %T, want ApprovePlanResult", response.Payload) + } + if payload.Status != "approved" || payload.PlanID != "plan-1" || payload.Revision != 2 { + t.Fatalf("payload = %#v", payload) + } + }) + + t.Run("approve plan runtime error", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + approvePlanFn: func(_ context.Context, _ ApprovePlanInput) (ApprovePlanResult, error) { + return ApprovePlanResult{}, errors.New("approve failed internals") + }, + } + response := handleApprovePlanFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionApprovePlan, + Payload: map[string]any{ + "session_id": "session-1", + "plan_id": "plan-1", + "revision": 1, + }, + }, stub) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + if response.Error.Message != "approve_plan failed" { + t.Fatalf("response message = %q, want %q", response.Error.Message, "approve_plan failed") + } + }) } func TestHandleSessionSkillFramesBranches(t *testing.T) { @@ -3799,6 +3897,29 @@ func TestHandleRenameSessionFrameErrors(t *testing.T) { t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) } }) + + t.Run("approve plan invalid runtime action", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + approvePlanFn: func(_ context.Context, _ ApprovePlanInput) (ApprovePlanResult, error) { + return ApprovePlanResult{}, ErrRuntimeInvalidAction + }, + } + response := handleApprovePlanFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionApprovePlan, + Payload: map[string]any{ + "session_id": "session-1", + "plan_id": "plan-1", + "revision": 1, + }, + }, stub) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInvalidAction.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInvalidAction.String()) + } + }) } func TestHandleListFilesFrameErrors(t *testing.T) { diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go index 657b3a517..18d62a61f 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -49,6 +49,28 @@ type PermissionResolutionInput struct { Decision PermissionResolutionDecision `json:"decision"` } +// ApprovePlanInput 表示批准当前计划 draft revision 的输入。 +type ApprovePlanInput struct { + // SubjectID 是请求方身份主体标识。 + SubjectID string `json:"subject_id,omitempty"` + // SessionID 是计划所属会话标识。 + SessionID string `json:"session_id"` + // PlanID 是目标计划标识。 + PlanID string `json:"plan_id"` + // Revision 是待批准的计划 revision。 + Revision int `json:"revision"` +} + +// ApprovePlanResult 表示批准计划后的稳定返回结构。 +type ApprovePlanResult struct { + // PlanID 是已批准计划标识。 + PlanID string `json:"plan_id"` + // Revision 是已批准 revision。 + Revision int `json:"revision"` + // Status 是批准后的计划状态,当前固定为 approved。 + Status string `json:"status"` +} + // RunInput 表示网关向下游运行端口发起 run 动作时的输入。 type RunInput struct { // SubjectID 是请求方身份主体标识。 @@ -926,6 +948,12 @@ type RuntimePort interface { CheckpointDiff(ctx context.Context, input CheckpointDiffInput) (CheckpointDiffResult, error) } +// PlanApprovalRuntimePort 定义批准计划的可选下游能力。 +type PlanApprovalRuntimePort interface { + // ApprovePlan 将指定 draft 计划 revision 推进到 approved。 + ApprovePlan(ctx context.Context, input ApprovePlanInput) (ApprovePlanResult, error) +} + // ManagementRuntimePort 定义前端管理面访问配置能力的可选下游端口。 type ManagementRuntimePort interface { // ListProviders 列出可管理 provider。 diff --git a/internal/gateway/metrics.go b/internal/gateway/metrics.go index fabfdc7ce..ccc9acb34 100644 --- a/internal/gateway/metrics.go +++ b/internal/gateway/metrics.go @@ -32,6 +32,7 @@ var allowedRPCMethodMetricLabels = map[string]struct{}{ strings.ToLower(protocol.MethodGatewayListSessionTodos): {}, strings.ToLower(protocol.MethodGatewayGetRuntimeSnapshot): {}, strings.ToLower(protocol.MethodGatewayResolvePermission): {}, + strings.ToLower(protocol.MethodGatewayApprovePlan): {}, strings.ToLower(protocol.MethodGatewayDeleteSession): {}, strings.ToLower(protocol.MethodGatewayRenameSession): {}, strings.ToLower(protocol.MethodGatewayListFiles): {}, diff --git a/internal/gateway/multi_workspace_runtime.go b/internal/gateway/multi_workspace_runtime.go index f0674d862..33c3bf52b 100644 --- a/internal/gateway/multi_workspace_runtime.go +++ b/internal/gateway/multi_workspace_runtime.go @@ -345,6 +345,19 @@ func (m *MultiWorkspaceRuntime) ResolvePermission(ctx context.Context, input Per return port.ResolvePermission(ctx, input) } +// ApprovePlan 将计划批准请求路由到当前工作区 RuntimePort 的可选计划审批能力。 +func (m *MultiWorkspaceRuntime) ApprovePlan(ctx context.Context, input ApprovePlanInput) (ApprovePlanResult, error) { + port, err := m.getPort(ctx) + if err != nil { + return ApprovePlanResult{}, err + } + approvalPort, ok := port.(PlanApprovalRuntimePort) + if !ok { + return ApprovePlanResult{}, fmt.Errorf("plan approval runtime port is unavailable") + } + return approvalPort.ApprovePlan(ctx, input) +} + func (m *MultiWorkspaceRuntime) ResolveUserQuestion(ctx context.Context, input UserQuestionAnswerInput) error { port, err := m.getPort(ctx) if err != nil { diff --git a/internal/gateway/multi_workspace_runtime_test.go b/internal/gateway/multi_workspace_runtime_test.go index fb3c65f32..c7209614a 100644 --- a/internal/gateway/multi_workspace_runtime_test.go +++ b/internal/gateway/multi_workspace_runtime_test.go @@ -24,6 +24,7 @@ type recordingPort struct { runCalls atomic.Int32 listSessionsCalls atomic.Int32 executeSysCalls atomic.Int32 + approvePlanCalls atomic.Int32 resolveUserCalls atomic.Int32 cancelCalls atomic.Int32 closed atomic.Int32 @@ -89,6 +90,15 @@ func (p *recordingPort) ResolvePermission(_ context.Context, _ PermissionResolut return nil } +func (p *recordingPort) ApprovePlan(_ context.Context, input ApprovePlanInput) (ApprovePlanResult, error) { + p.approvePlanCalls.Add(1) + return ApprovePlanResult{ + PlanID: input.PlanID, + Revision: input.Revision, + Status: "approved", + }, nil +} + func (p *recordingPort) ResolveUserQuestion(_ context.Context, _ UserQuestionAnswerInput) error { p.resolveUserCalls.Add(1) return nil @@ -508,6 +518,36 @@ func TestMultiWorkspaceRuntime_ResolveUserQuestionRoutesByWorkspace(t *testing.T } } +func TestMultiWorkspaceRuntime_ApprovePlanRoutesByWorkspace(t *testing.T) { + idx, alpha, beta := setupIndex(t) + builder := newTestBuilder() + mw := NewMultiWorkspaceRuntime(idx, alpha.Hash, builder.build) + t.Cleanup(func() { _ = mw.Close() }) + + result, err := mw.ApprovePlan(ctxWithHash(t, beta.Hash), ApprovePlanInput{ + SessionID: "session-1", + PlanID: "plan-1", + Revision: 2, + }) + if err != nil { + t.Fatalf("ApprovePlan: %v", err) + } + if result.PlanID != "plan-1" || result.Revision != 2 || result.Status != "approved" { + t.Fatalf("ApprovePlan result = %#v", result) + } + + betaPort := builder.portFor(beta.Path) + if betaPort == nil { + t.Fatalf("beta port should be built") + } + if got := betaPort.approvePlanCalls.Load(); got != 1 { + t.Fatalf("beta approve plan calls = %d, want 1", got) + } + if alphaPort := builder.portFor(alpha.Path); alphaPort != nil && alphaPort.approvePlanCalls.Load() != 0 { + t.Fatalf("alpha approve plan should not be called, got %d", alphaPort.approvePlanCalls.Load()) + } +} + func TestMultiWorkspaceRuntime_CreatePersistsIndex(t *testing.T) { idx, alpha, _ := setupIndex(t) builder := newTestBuilder() @@ -747,6 +787,7 @@ func TestMultiWorkspaceRuntime_ListWorkspacesMatchesIndex(t *testing.T) { // guard against future drift: MultiWorkspaceRuntime must implement RuntimePort and ManagementRuntimePort. var _ RuntimePort = (*MultiWorkspaceRuntime)(nil) var _ ManagementRuntimePort = (*MultiWorkspaceRuntime)(nil) +var _ PlanApprovalRuntimePort = (*MultiWorkspaceRuntime)(nil) // guard helper: ensure recordingPort builds correctly under sync access. func TestRecordingPort_Concurrent(t *testing.T) { diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index 41e1193e5..8a2601d30 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -63,6 +63,8 @@ const ( MethodGatewayCheckpointDiff = "checkpoint.diff" // MethodGatewayResolvePermission 表示提交权限审批决策。 MethodGatewayResolvePermission = "gateway.resolvePermission" + // MethodGatewayApprovePlan 表示批准当前 draft 计划 revision。 + MethodGatewayApprovePlan = "gateway.approvePlan" // MethodGatewayUserQuestionAnswer 表示提交 ask_user 回答。 MethodGatewayUserQuestionAnswer = "gateway.userQuestionAnswer" // MethodGatewayDeleteSession 表示删除/归档会话。 @@ -366,6 +368,13 @@ type ResolvePermissionParams struct { Decision string `json:"decision"` } +// ApprovePlanParams 表示 gateway.approvePlan 参数。 +type ApprovePlanParams struct { + SessionID string `json:"session_id"` + PlanID string `json:"plan_id"` + Revision int `json:"revision"` +} + // UserQuestionAnswerParams 表示 gateway.userQuestionAnswer 参数。 type UserQuestionAnswerParams struct { RequestID string `json:"request_id"` @@ -779,6 +788,15 @@ func NormalizeJSONRPCRequest(request JSONRPCRequest) (NormalizedRequest, *JSONRP normalized.Action = "resolve_permission" normalized.Payload = params return normalized, nil + case MethodGatewayApprovePlan: + params, parseErr := decodeApprovePlanParams(request.Params) + if parseErr != nil { + return normalized, parseErr + } + normalized.Action = "approve_plan" + normalized.SessionID = strings.TrimSpace(params.SessionID) + normalized.Payload = params + return normalized, nil case MethodGatewayUserQuestionAnswer: params, parseErr := decodeUserQuestionAnswerParams(request.Params) if parseErr != nil { @@ -1514,6 +1532,24 @@ func decodeResolvePermissionParams(raw json.RawMessage) (ResolvePermissionParams }) } +// decodeApprovePlanParams 对 gateway.approvePlan 的 params 执行反序列化与字段校验。 +func decodeApprovePlanParams(raw json.RawMessage) (ApprovePlanParams, *JSONRPCError) { + return decodeParams(raw, "gateway.approvePlan", func(p *ApprovePlanParams) *JSONRPCError { + p.SessionID = strings.TrimSpace(p.SessionID) + p.PlanID = strings.TrimSpace(p.PlanID) + if p.SessionID == "" { + return NewJSONRPCError(JSONRPCCodeInvalidParams, "missing required field: params.session_id", GatewayCodeMissingRequiredField) + } + if p.PlanID == "" { + return NewJSONRPCError(JSONRPCCodeInvalidParams, "missing required field: params.plan_id", GatewayCodeMissingRequiredField) + } + if p.Revision <= 0 { + return NewJSONRPCError(JSONRPCCodeInvalidParams, "invalid field: params.revision", GatewayCodeInvalidFrame) + } + return nil + }) +} + // decodeUserQuestionAnswerParams 对 gateway.userQuestionAnswer 的 params 执行反序列化与字段校验。 func decodeUserQuestionAnswerParams(raw json.RawMessage) (UserQuestionAnswerParams, *JSONRPCError) { return decodeParams(raw, "gateway.userQuestionAnswer", func(p *UserQuestionAnswerParams) *JSONRPCError { diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go index 2d92c2776..c894dd62e 100644 --- a/internal/gateway/protocol/jsonrpc_test.go +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -316,6 +316,55 @@ func TestNormalizeJSONRPCRequestCheckpointMethods(t *testing.T) { }) } +func TestNormalizeJSONRPCRequestApprovePlan(t *testing.T) { + t.Run("success", func(t *testing.T) { + normalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"approve-plan-1"`), + Method: MethodGatewayApprovePlan, + Params: json.RawMessage(`{"session_id":" session-1 ","plan_id":" plan-1 ","revision":2}`), + }) + if rpcErr != nil { + t.Fatalf("normalize approvePlan request: %v", rpcErr) + } + if normalized.Action != "approve_plan" { + t.Fatalf("action = %q, want %q", normalized.Action, "approve_plan") + } + if normalized.SessionID != "session-1" { + t.Fatalf("session_id = %q, want %q", normalized.SessionID, "session-1") + } + params, ok := normalized.Payload.(ApprovePlanParams) + if !ok { + t.Fatalf("payload type = %T, want ApprovePlanParams", normalized.Payload) + } + if params.PlanID != "plan-1" || params.Revision != 2 { + t.Fatalf("params = %#v, want plan-1 revision 2", params) + } + }) + + tests := []struct { + name string + params string + }{ + {name: "missing session", params: `{"session_id":" ","plan_id":"plan-1","revision":1}`}, + {name: "missing plan", params: `{"session_id":"session-1","plan_id":" ","revision":1}`}, + {name: "invalid revision", params: `{"session_id":"session-1","plan_id":"plan-1","revision":0}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"approve-plan-invalid"`), + Method: MethodGatewayApprovePlan, + Params: json.RawMessage(tt.params), + }) + if rpcErr == nil || rpcErr.Code != JSONRPCCodeInvalidParams { + t.Fatalf("expected invalid params error, got %#v", rpcErr) + } + }) + } +} + func TestNormalizeJSONRPCRequestRuntimeMethods(t *testing.T) { runRequest := JSONRPCRequest{ JSONRPC: JSONRPCVersion, diff --git a/internal/gateway/registry.go b/internal/gateway/registry.go index 853a665f3..95e129dee 100644 --- a/internal/gateway/registry.go +++ b/internal/gateway/registry.go @@ -59,6 +59,7 @@ func (r *ActionRegistry) initCore() { r.core[FrameActionListSessionTodos] = handleListSessionTodosFrame r.core[FrameActionGetRuntimeSnapshot] = handleGetRuntimeSnapshotFrame r.core[FrameActionResolvePermission] = handleResolvePermissionFrame + r.core[FrameActionApprovePlan] = handleApprovePlanFrame r.core[FrameActionUserQuestionAnswer] = handleUserQuestionAnswerFrame r.core[FrameActionDeleteSession] = handleDeleteSessionFrame r.core[FrameActionRenameSession] = handleRenameSessionFrame diff --git a/internal/gateway/rpc_dispatch.go b/internal/gateway/rpc_dispatch.go index 0360b000d..3b5f38ae0 100644 --- a/internal/gateway/rpc_dispatch.go +++ b/internal/gateway/rpc_dispatch.go @@ -365,6 +365,7 @@ func requiresSession(action FrameAction) bool { FrameActionActivateSessionSkill, FrameActionDeactivateSessionSkill, FrameActionListSessionSkills, + FrameActionApprovePlan, FrameActionDeleteSession, FrameActionRenameSession, FrameActionSetSessionModel, diff --git a/internal/gateway/rpc_dispatch_test.go b/internal/gateway/rpc_dispatch_test.go index 1f2e8e57f..1994e3e51 100644 --- a/internal/gateway/rpc_dispatch_test.go +++ b/internal/gateway/rpc_dispatch_test.go @@ -25,6 +25,8 @@ type rpcRunCaptureRuntimeStub struct { deactivateSkillFn func(ctx context.Context, input SessionSkillMutationInput) error listSessionSkillsFn func(ctx context.Context, input ListSessionSkillsInput) ([]SessionSkillState, error) listAvailableFn func(ctx context.Context, input ListAvailableSkillsInput) ([]AvailableSkillState, error) + approvePlanInput ApprovePlanInput + approvePlanFn func(ctx context.Context, input ApprovePlanInput) (ApprovePlanResult, error) loadSessionFn func(ctx context.Context, input LoadSessionInput) (Session, error) listProvidersFn func(ctx context.Context, input ListProvidersInput) ([]ProviderOption, error) createProviderFn func(ctx context.Context, input CreateProviderInput) (ProviderSelectionResult, error) @@ -115,6 +117,17 @@ func (s *rpcRunCaptureRuntimeStub) ResolvePermission(_ context.Context, _ Permis return nil } +func (s *rpcRunCaptureRuntimeStub) ApprovePlan( + ctx context.Context, + input ApprovePlanInput, +) (ApprovePlanResult, error) { + s.approvePlanInput = input + if s.approvePlanFn != nil { + return s.approvePlanFn(ctx, input) + } + return ApprovePlanResult{}, nil +} + func (s *rpcRunCaptureRuntimeStub) ResolveUserQuestion(_ context.Context, _ UserQuestionAnswerInput) error { return nil } @@ -608,6 +621,83 @@ func TestDispatchRPCRequestResolvePermissionDoesNotRequireSession(t *testing.T) } } +func TestDispatchRPCRequestApprovePlanAllowedForAuthenticatedWebSocket(t *testing.T) { + authState := NewConnectionAuthState() + authState.MarkAuthenticated("local_admin") + ctx := WithRequestSource(context.Background(), RequestSourceWS) + ctx = WithRequestACL(ctx, NewStrictControlPlaneACL()) + ctx = WithConnectionAuthState(ctx, authState) + + runtimeStub := &rpcRunCaptureRuntimeStub{ + approvePlanFn: func(_ context.Context, input ApprovePlanInput) (ApprovePlanResult, error) { + if input.SubjectID != "local_admin" { + t.Fatalf("subject_id = %q, want %q", input.SubjectID, "local_admin") + } + if input.SessionID != "session-1" || input.PlanID != "plan-1" || input.Revision != 2 { + t.Fatalf("approve input = %#v", input) + } + return ApprovePlanResult{ + PlanID: input.PlanID, + Revision: input.Revision, + Status: "approved", + }, nil + }, + } + + response := dispatchRPCRequest(ctx, protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-approve-plan"`), + Method: protocol.MethodGatewayApprovePlan, + Params: json.RawMessage(`{"session_id":"session-1","plan_id":"plan-1","revision":2}`), + }, runtimeStub) + if response.Error != nil { + t.Fatalf("approvePlan should pass strict WS ACL, got error: %+v", response.Error) + } + + frame, err := decodeJSONRPCResultFrame(response) + if err != nil { + t.Fatalf("decode approvePlan result frame: %v", err) + } + if frame.Action != FrameActionApprovePlan { + t.Fatalf("response action = %q, want %q", frame.Action, FrameActionApprovePlan) + } + payload, ok := frame.Payload.(map[string]any) + if !ok { + t.Fatalf("payload type = %T, want map[string]any", frame.Payload) + } + if payload["plan_id"] != "plan-1" || payload["status"] != "approved" || payload["revision"] != float64(2) { + t.Fatalf("payload = %#v, want approved plan result", payload) + } + if runtimeStub.approvePlanInput.PlanID != "plan-1" { + t.Fatalf("approvePlan was not called, captured input = %#v", runtimeStub.approvePlanInput) + } +} + +func TestDispatchRPCRequestApprovePlanInvalidRuntimeAction(t *testing.T) { + authState := NewConnectionAuthState() + authState.MarkAuthenticated("local_admin") + ctx := WithRequestSource(context.Background(), RequestSourceWS) + ctx = WithRequestACL(ctx, NewStrictControlPlaneACL()) + ctx = WithConnectionAuthState(ctx, authState) + + response := dispatchRPCRequest(ctx, protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-approve-plan-invalid"`), + Method: protocol.MethodGatewayApprovePlan, + Params: json.RawMessage(`{"session_id":"session-1","plan_id":"plan-1","revision":2}`), + }, &rpcRunCaptureRuntimeStub{ + approvePlanFn: func(_ context.Context, _ ApprovePlanInput) (ApprovePlanResult, error) { + return ApprovePlanResult{}, ErrRuntimeInvalidAction + }, + }) + if response.Error == nil { + t.Fatal("approvePlan invalid action should return JSON-RPC error") + } + if response.Error.Data == nil || response.Error.Data.GatewayCode != ErrorCodeInvalidAction.String() { + t.Fatalf("approvePlan error = %#v, want invalid_action", response.Error) + } +} + func TestDispatchRPCRequestExecuteSystemToolDoesNotRequireSession(t *testing.T) { ctx := WithRequestSource(context.Background(), RequestSourceIPC) ctx = WithRequestACL(ctx, NewStrictControlPlaneACL()) @@ -1347,6 +1437,16 @@ func TestDispatchRPCRequestMetricsGrowForTUIMethodSequence(t *testing.T) { t.Fatalf("compact response error: %+v", compact.Error) } + approvePlan := dispatchRPCRequest(ctx, protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-approve-tui"`), + Method: protocol.MethodGatewayApprovePlan, + Params: json.RawMessage(`{"session_id":"session-tui","plan_id":"plan-tui","revision":1}`), + }, &rpcRunCaptureRuntimeStub{}) + if approvePlan.Error != nil { + t.Fatalf("approvePlan response error: %+v", approvePlan.Error) + } + listSessions := dispatchRPCRequest(ctx, protocol.JSONRPCRequest{ JSONRPC: protocol.JSONRPCVersion, ID: json.RawMessage(`"req-list-tui"`), @@ -1367,6 +1467,9 @@ func TestDispatchRPCRequestMetricsGrowForTUIMethodSequence(t *testing.T) { if snapshot["ipc|gateway.compact|ok"] == 0 { t.Fatalf("expected compact metric to grow, snapshot=%#v", snapshot) } + if snapshot["ipc|gateway.approveplan|ok"] == 0 { + t.Fatalf("expected approvePlan metric to grow, snapshot=%#v", snapshot) + } if snapshot["ipc|gateway.listsessions|ok"] == 0 { t.Fatalf("expected listSessions metric to grow, snapshot=%#v", snapshot) } diff --git a/internal/gateway/runtime_errors.go b/internal/gateway/runtime_errors.go index df14303ee..63c585664 100644 --- a/internal/gateway/runtime_errors.go +++ b/internal/gateway/runtime_errors.go @@ -7,4 +7,6 @@ var ( ErrRuntimeAccessDenied = errors.New("runtime access denied") // ErrRuntimeResourceNotFound 表示运行时未找到目标资源。 ErrRuntimeResourceNotFound = errors.New("runtime resource not found") + // ErrRuntimeInvalidAction 表示运行时拒绝了语义非法或已过期的动作。 + ErrRuntimeInvalidAction = errors.New("runtime invalid action") ) diff --git a/internal/gateway/security.go b/internal/gateway/security.go index 2cb1406d9..66d741de8 100644 --- a/internal/gateway/security.go +++ b/internal/gateway/security.go @@ -72,6 +72,7 @@ func fullControlPlaneMethods() map[string]struct{} { "checkpoint.undoRestore", "checkpoint.diff", "gateway.resolvePermission", + "gateway.approvePlan", "gateway.userQuestionAnswer", "gateway.user_question_answer", "gateway.deleteSession", diff --git a/internal/gateway/security_test.go b/internal/gateway/security_test.go index 183c7c81b..55e955399 100644 --- a/internal/gateway/security_test.go +++ b/internal/gateway/security_test.go @@ -39,9 +39,14 @@ func TestStrictACLAllowlist(t *testing.T) { {source: RequestSourceHTTP, method: "checkpoint.restore", want: true}, {source: RequestSourceHTTP, method: "checkpoint.undoRestore", want: true}, {source: RequestSourceHTTP, method: "checkpoint.diff", want: true}, + {source: RequestSourceIPC, method: "gateway.approvePlan", want: true}, + {source: RequestSourceHTTP, method: "gateway.approvePlan", want: true}, + {source: RequestSourceWS, method: "gateway.approvePlan", want: true}, + {source: RequestSourceSSE, method: "gateway.approvePlan", want: false}, {source: RequestSourceHTTP, method: "gateway.userQuestionAnswer", want: true}, {source: RequestSourceHTTP, method: "gateway.user_question_answer", want: true}, {source: RequestSourceUnknown, method: "gateway.ping", want: false}, + {source: RequestSourceUnknown, method: "gateway.approvePlan", want: false}, } for _, tc := range cases { assertACLAllowed(t, acl, tc.source, tc.method, tc.want) diff --git a/internal/gateway/types.go b/internal/gateway/types.go index 1b3e4a8f6..16d207394 100644 --- a/internal/gateway/types.go +++ b/internal/gateway/types.go @@ -58,6 +58,8 @@ const ( FrameActionGetRuntimeSnapshot FrameAction = "runtime_snapshot_get" // FrameActionResolvePermission 表示提交一次权限审批决策。 FrameActionResolvePermission FrameAction = "resolve_permission" + // FrameActionApprovePlan 表示批准当前 draft 计划 revision。 + FrameActionApprovePlan FrameAction = "approve_plan" // FrameActionUserQuestionAnswer 表示提交一次 ask_user 回答。 FrameActionUserQuestionAnswer FrameAction = "user_question_answer" // FrameActionDeleteSession 表示删除/归档会话。 diff --git a/internal/gateway/validate.go b/internal/gateway/validate.go index ebd979704..985ee96e3 100644 --- a/internal/gateway/validate.go +++ b/internal/gateway/validate.go @@ -98,6 +98,8 @@ func validateRequestFrame(frame MessageFrame) *FrameError { return nil case FrameActionResolvePermission: return validateResolvePermissionFrame(frame) + case FrameActionApprovePlan: + return validateApprovePlanFrame(frame) case FrameActionUserQuestionAnswer: return validateUserQuestionAnswerFrame(frame) case FrameActionRestoreCheckpoint, @@ -179,6 +181,42 @@ func decodePermissionResolutionInput(payload any) (PermissionResolutionInput, er return input, nil } +// validateApprovePlanFrame 校验 approve_plan 动作所需字段。 +func validateApprovePlanFrame(frame MessageFrame) *FrameError { + if frame.Payload == nil { + return NewMissingRequiredFieldError("payload") + } + + input, err := decodeApprovePlanPayload(frame.Payload) + if err != nil { + return err + } + if strings.TrimSpace(input.SessionID) == "" { + return NewMissingRequiredFieldError("payload.session_id") + } + if strings.TrimSpace(input.PlanID) == "" { + return NewMissingRequiredFieldError("payload.plan_id") + } + if input.Revision <= 0 { + return NewFrameError(ErrorCodeInvalidAction, "invalid approve_plan revision") + } + + return nil +} + +// decodeApprovePlanPayload 将 payload 解析为批准计划输入。 +func decodeApprovePlanPayload(payload any) (ApprovePlanInput, *FrameError) { + var params protocol.ApprovePlanParams + if err := decodePayload(payload, ¶ms); err != nil { + return ApprovePlanInput{}, NewFrameError(ErrorCodeInvalidFrame, "invalid approve_plan payload") + } + return ApprovePlanInput{ + SessionID: strings.TrimSpace(params.SessionID), + PlanID: strings.TrimSpace(params.PlanID), + Revision: params.Revision, + }, nil +} + // decodeRenameSessionPayload 解析 renameSession 的负载参数。 func decodeRenameSessionPayload(payload any) (renameSessionParams, *FrameError) { switch typed := payload.(type) { @@ -595,6 +633,7 @@ func isValidFrameAction(action FrameAction) bool { FrameActionListSessionTodos, FrameActionGetRuntimeSnapshot, FrameActionResolvePermission, + FrameActionApprovePlan, FrameActionDeleteSession, FrameActionRenameSession, FrameActionListFiles, diff --git a/internal/runtime/plan_approval.go b/internal/runtime/plan_approval.go index f1be721dd..e083ade68 100644 --- a/internal/runtime/plan_approval.go +++ b/internal/runtime/plan_approval.go @@ -7,6 +7,25 @@ import ( "time" ) +var ( + // ErrPlanApprovalCurrentPlanMissing 表示当前会话没有可批准的计划。 + ErrPlanApprovalCurrentPlanMissing = errors.New("runtime plan approval current plan missing") + // ErrPlanApprovalPlanIDMismatch 表示客户端批准的计划 ID 已不是当前计划。 + ErrPlanApprovalPlanIDMismatch = errors.New("runtime plan approval plan id mismatch") + // ErrPlanApprovalRevisionMismatch 表示客户端批准的 revision 已过期或非法。 + ErrPlanApprovalRevisionMismatch = errors.New("runtime plan approval revision mismatch") + // ErrPlanApprovalStatusInvalid 表示当前计划状态不允许批准。 + ErrPlanApprovalStatusInvalid = errors.New("runtime plan approval status invalid") +) + +// IsPlanApprovalInvalidError 判断错误是否属于可预期的计划审批业务拒绝。 +func IsPlanApprovalInvalidError(err error) bool { + return errors.Is(err, ErrPlanApprovalCurrentPlanMissing) || + errors.Is(err, ErrPlanApprovalPlanIDMismatch) || + errors.Is(err, ErrPlanApprovalRevisionMismatch) || + errors.Is(err, ErrPlanApprovalStatusInvalid) +} + // ApproveCurrentPlan 显式批准当前完整计划 revision,并安排下一轮做一次完整计划对齐。 func (s *Service) ApproveCurrentPlan(ctx context.Context, input ApproveCurrentPlanInput) error { if err := ctx.Err(); err != nil { diff --git a/internal/runtime/planning.go b/internal/runtime/planning.go index 0e4bbc318..716c39fad 100644 --- a/internal/runtime/planning.go +++ b/internal/runtime/planning.go @@ -399,16 +399,16 @@ func rememberFullPlanRevision(session *agentsession.Session) bool { // approveCurrentPlan 显式批准当前 draft revision,并安排下一轮做一次完整计划对齐。 func approveCurrentPlan(session *agentsession.Session, planID string, revision int) error { if session == nil || session.CurrentPlan == nil { - return fmt.Errorf("runtime: current plan does not exist") + return fmt.Errorf("%w: current plan does not exist", ErrPlanApprovalCurrentPlanMissing) } if strings.TrimSpace(planID) == "" || strings.TrimSpace(session.CurrentPlan.ID) != strings.TrimSpace(planID) { - return fmt.Errorf("runtime: current plan id does not match") + return fmt.Errorf("%w: current plan id does not match", ErrPlanApprovalPlanIDMismatch) } if revision <= 0 || session.CurrentPlan.Revision != revision { - return fmt.Errorf("runtime: current plan revision does not match") + return fmt.Errorf("%w: current plan revision does not match", ErrPlanApprovalRevisionMismatch) } if session.CurrentPlan.Status != agentsession.PlanStatusDraft { - return fmt.Errorf("runtime: current plan status %q cannot be approved", session.CurrentPlan.Status) + return fmt.Errorf("%w: current plan status %q cannot be approved", ErrPlanApprovalStatusInvalid, session.CurrentPlan.Status) } session.CurrentPlan = session.CurrentPlan.Clone() session.CurrentPlan.Status = agentsession.PlanStatusApproved diff --git a/internal/runtime/planning_test.go b/internal/runtime/planning_test.go index 406933537..e09d77563 100644 --- a/internal/runtime/planning_test.go +++ b/internal/runtime/planning_test.go @@ -1,6 +1,7 @@ package runtime import ( + "errors" "reflect" "strings" "testing" @@ -535,7 +536,8 @@ func TestApproveCurrentPlanValidationErrors(t *testing.T) { t.Parallel() session := agentsession.New("approve validation") - if err := approveCurrentPlan(&session, "plan-1", 1); err == nil { + if err := approveCurrentPlan(&session, "plan-1", 1); !errors.Is(err, ErrPlanApprovalCurrentPlanMissing) || + !IsPlanApprovalInvalidError(err) { t.Fatal("expected error when current plan does not exist") } @@ -549,15 +551,18 @@ func TestApproveCurrentPlanValidationErrors(t *testing.T) { }, } - if err := approveCurrentPlan(&session, "plan-2", 2); err == nil { - t.Fatal("expected id mismatch error") + if err := approveCurrentPlan(&session, "plan-2", 2); !errors.Is(err, ErrPlanApprovalPlanIDMismatch) || + !IsPlanApprovalInvalidError(err) { + t.Fatalf("expected id mismatch error, got %v", err) } - if err := approveCurrentPlan(&session, "plan-1", 1); err == nil { - t.Fatal("expected revision mismatch error") + if err := approveCurrentPlan(&session, "plan-1", 1); !errors.Is(err, ErrPlanApprovalRevisionMismatch) || + !IsPlanApprovalInvalidError(err) { + t.Fatalf("expected revision mismatch error, got %v", err) } session.CurrentPlan.Status = agentsession.PlanStatusApproved - if err := approveCurrentPlan(&session, "plan-1", 2); err == nil { - t.Fatal("expected status mismatch error") + if err := approveCurrentPlan(&session, "plan-1", 2); !errors.Is(err, ErrPlanApprovalStatusInvalid) || + !IsPlanApprovalInvalidError(err) { + t.Fatalf("expected status mismatch error, got %v", err) } } diff --git a/web/src/api/gateway.test.ts b/web/src/api/gateway.test.ts index 336b3f403..bb4cf0eec 100644 --- a/web/src/api/gateway.test.ts +++ b/web/src/api/gateway.test.ts @@ -53,10 +53,12 @@ describe('GatewayAPI', () => { it('maps permission and user question resolution', async () => { await api.resolvePermission({ request_id: 'r1', decision: 'allow_once' }) + await api.approvePlan({ session_id: 's1', plan_id: 'p1', revision: 2 }) await api.resolveUserQuestion({ request_id: 'q1', status: 'answered', message: 'ok' }) expect(call).toHaveBeenNthCalledWith(1, Method.ResolvePermission, { request_id: 'r1', decision: 'allow_once' }) - expect(call).toHaveBeenNthCalledWith(2, Method.UserQuestionAnswer, { request_id: 'q1', status: 'answered', message: 'ok' }) + expect(call).toHaveBeenNthCalledWith(2, Method.ApprovePlan, { session_id: 's1', plan_id: 'p1', revision: 2 }) + expect(call).toHaveBeenNthCalledWith(3, Method.UserQuestionAnswer, { request_id: 'q1', status: 'answered', message: 'ok' }) }) }) diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts index acbff8808..18357270b 100644 --- a/web/src/api/gateway.ts +++ b/web/src/api/gateway.ts @@ -18,6 +18,8 @@ import { type CheckpointDiffParams, type CheckpointDiffResult, type ResolvePermissionParams, + type ApprovePlanParams, + type ApprovePlanResult, type ResolveUserQuestionParams, type Session, type RunAckResult, @@ -144,6 +146,10 @@ export class GatewayAPI { return this.ws.call(Method.ResolvePermission, params) } + async approvePlan(params: ApprovePlanParams) { + return this.ws.call(Method.ApprovePlan, params) + } + /** 提交 ask_user 回答 */ async resolveUserQuestion(params: ResolveUserQuestionParams) { return this.ws.call(Method.UserQuestionAnswer, params) diff --git a/web/src/api/protocol.ts b/web/src/api/protocol.ts index 96bab0ca2..c60068093 100644 --- a/web/src/api/protocol.ts +++ b/web/src/api/protocol.ts @@ -21,6 +21,7 @@ export const Method = { UndoRestore: "checkpoint.undoRestore", CheckpointDiff: "checkpoint.diff", ResolvePermission: "gateway.resolvePermission", + ApprovePlan: "gateway.approvePlan", UserQuestionAnswer: "gateway.userQuestionAnswer", ExecuteSystemTool: "gateway.executeSystemTool", ActivateSessionSkill: "gateway.activateSessionSkill", @@ -62,6 +63,7 @@ export const FrameType = { // 帧动作 export const FrameAction = { Run: "run", + ApprovePlan: "approve_plan", ListProviders: "list_providers", CreateCustomProvider: "create_custom_provider", DeleteCustomProvider: "delete_custom_provider", @@ -265,6 +267,12 @@ export interface ResolvePermissionParams { decision: string; } +export interface ApprovePlanParams { + session_id: string; + plan_id: string; + revision: number; +} + /** gateway.userQuestionAnswer 参数 */ export interface ResolveUserQuestionParams { request_id: string; @@ -400,6 +408,8 @@ export type ListSessionsResult = RPCResult<{ sessions: SessionSummary[] }>; /** gateway.cancel 响应 */ export type CancelResult = RPCResult<{ canceled: boolean; run_id: string }>; +export type ApprovePlanResult = RPCResult<{ plan_id: string; revision: number; status: string }>; + export interface TodoViewItem { id: string; content: string; diff --git a/web/src/components/chat/ChatPanel.test.tsx b/web/src/components/chat/ChatPanel.test.tsx index 988818266..8bf7660cb 100644 --- a/web/src/components/chat/ChatPanel.test.tsx +++ b/web/src/components/chat/ChatPanel.test.tsx @@ -16,7 +16,7 @@ vi.mock('./MessageList', () => ({ })) vi.mock('./ChatInput', () => ({ - default: () =>
, + default: () =>